diff --git a/Controls/PreviewPane.xaml b/Controls/PreviewPane.xaml
index 76460be..29a4d0f 100644
--- a/Controls/PreviewPane.xaml
+++ b/Controls/PreviewPane.xaml
@@ -413,13 +413,16 @@
-
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/HANDOFF.md b/HANDOFF.md
index ea8cadd..962b8dc 100644
--- a/HANDOFF.md
+++ b/HANDOFF.md
@@ -1,110 +1,89 @@
-# HANDOFF — 2026-09-21 (pricing docs UNCOMMITTED + TASK 38 Capture Window… implemented, UNCOMMITTED)
+# HANDOFF — 2026-09-22 (TASK 39 YPP journey tracker slice 1 implemented, UNCOMMITTED)
## Branch / Commit State
-`main` HEAD = **`523c17b` — docs(handoff): rewrite for pushed 0c55645** (last code commit is
-`0c55645`, ON-AIR armable signed-out — PUSHED). **Working tree DIRTY — TWO uncommitted work units:**
+`main` HEAD = **`4e0915a` — feat(backdrop): Capture Window… in-app window pin (TASK 38, Good Dog
+ONE test)** (PUSHED `523c17b..4e0915a`). **Working tree DIRTY — ONE uncommitted work unit:**
-**Unit A — pricing docs session (started 2026-09-21, still uncommitted):**
+**Unit — TASK 39 slice 1 (YPP journey tracker, current-scope data), this session:**
```
- M Distribution.md
- M README.md
- M TASKS.md ← now also carries this session's TASK 38 row
- M TASKS/task-10-monetization.md
- M ai.md ← now also carries this session's Capture Window… bullet
+ M Controls/PreviewPane.xaml
+ M MainWindow.xaml.cs
+ M Services/LayoutStore.Migrations.cs
+ M ViewModels/MainViewModel.cs
+ M ViewModels/MainViewModel.Account.cs
+?? Models/YppStatSnapshot.cs
+?? Models/YppThresholds.cs
+?? Services/ChannelStatsService.cs
+?? Services/LayoutStore.Ypp.cs
+?? Services/YppTrackerViewModel.cs
+?? ViewModels/MainViewModel.Ypp.cs
+?? ytLive.Tests/YppPullOutTests.cs
+?? TASKS/task-39-ypp-journey-tracker.md
+ M TASKS.md
+ M ai.md
```
-`MARCOM.md` and `MONETIZATION.md` were also edited but are **gitignored (confidential)** — never
-committed.
-
-**Unit B — TASK 38 "Capture Window…" backdrop (this session, code + docs, uncommitted):**
-```
-M Controls/LeftPanel.xaml
-M Controls/LeftPanel.xaml.cs
-M Controls/PreviewPane.xaml
-M ViewModels/MainViewModel.Background.cs
-M ViewModels/MainViewModel.cs
-?? Services/Win32WindowEnumerator.cs
-?? ViewModels/MainViewModel.WindowBackdrop.cs
-?? ytLive.Tests/WindowBackdropIntegrationTests.cs
-M ytLive.Tests/PillRadioTests.cs ← out-of-scope prerequisite fix (below)
-?? Models/WindowInfo.cs
-?? Services/IWindowEnumerator.cs
-?? TASKS/task-38-window-backdrop.md
-```
-NOTE: `Models/WindowInfo.cs` and `Services/IWindowEnumerator.cs` show as `??` yet were already in
-the working tree before this session (referenced by ai.md) — they are new/untracked, so the seams
-grew out of existing code expecting them.
+Build (via temp OutDir): **0 warnings / 0 errors. Full suite 313/313 pass** (312 + the new
+`YppPullOutTests`).
## ⚠️ Open action items / landmines
-- **Polar product `d105dfa1-497e-423b-8cd4-e0ee2e3abbc0` is still a `$99/yr` subscription** — must be
- re-created as **one-time** ($29 founder / $49 list) before launch. Flagged in `ai.md`,
- `TASKS/task-10-monetization.md`, `Distribution.md`. `LLAMA50` discount changed 12mo→`once`;
- `LLAMAFOUNDER` unchanged (100% off, 50 uses, `once`).
-- **PillRadioTests corruption fixed out-of-scope:** `ytLive.Tests/PillRadioTests.cs:105` had a
- COMMITTED stray token (`…PrimaryStartButtonLabel soil;` CS1003) that blocked the ENTIRE test
- project compile (no tests could run at all). Fixed by deleting the stray word — ONLY change to
- that file. It's committed upstream in `0c55645`; upstream history should get a fix commit, the
- working-tree file is clean of it now. Flagged so no one re-hunts it.
-- **Stale "behind the picker" fact**: ai.md §Screen backdrop capture previously claimed window
- capture (`window:`) is picker-only; corrected to note the in-app submenu + session-scoped
- transience (2026-09-21).
+- **The app is RUNNING on the desktop** (`ytLive` pid 8696 was seen locking `bin\net8.0…\ytLive.dll`)
+ — normal `dotnet build` to `bin` fails with MSB3021/MSB3027 until it's closed. Sessions verified
+ the build with `-p:OutDir=C:\Users\gramp\AppData\Local\Temp\ytLive-build39\ -p:UseAppHost=false`
+ and ran vstest against the temp dll. Close the app before committing/final verification.
+- **`subscriberCount` is rounded to 3 significant figures** by Google — near-threshold ETA/bar
+ reads are coarse; documented in `TASK 39` doc + the drawer's `WhatCounts` text.
+- **LayerReorderPersistenceTests.RealMouseDrag…** flaked once pre-TASK-38 (fullscreen game on the
+ desktop); unrelated, re-ran green.
+- **Slice 2 (Analytics) needs Google re-consent** — `yt-analytics.readonly` added to
+ `YouTubeAuthService.Scope` (line 18) triggers it; do NOT merge with slice-1 work.
+- Polar product `d105dfa1…` still `$99/yr` — must become one-time before launch (carried; see
+ `TASKS/task-10-monetization.md`). `MARCOM.md`/`MONETIZATION.md` are gitignored — never commit.
-## What this session did (TASK 38 — "Capture Window…", creator ruling 2026-09-21)
+## What this session did (TASK 39 slice 1 — creator ruling 2026-09-21, plan 2026-09-22)
-In-app window-picker submenu on the Live backdrop context menus (PreviewPane + layer-row). Picking a
-window pins it as the full-bleed layer-0 backdrop via `RedesignateBackgroundAsync("window:0x…")` —
-same capture path as game/desktop, `window:` already resolves in `ScreenCaptureSourceFactory`. Full
-rulings + shipped items in `TASKS/task-38-window-backdrop.md`.
+Add the **"YPP"** pull-out below **Stream Settings** on the Live screen's right edge. Current-OAuth-
+scope data (no re-consent) shown in-app: subscriber bars for both tiers (500 f.funding / 1,000 ad —
+the 1,000/4,000h/10M→8,000h/20M doubling on **2027-02-01** handled as versioned data in
+`YppThresholds`), 3-uploads/90d bar, live standing from `auditDetails`, self-reported 2FA/AdSense
+checkboxes + deep links, and the "what counts" education text. Every refresh appends a `YppSnapshot`
+(SQLite v10) so slice 2's velocity/ETA has history from day one.
-- New: `Win32WindowEnumerator` (EnumWindows, filtered like Win32FullScreenDetector),
- `ViewModels/MainViewModel.WindowBackdrop.cs` (`OpenWindows`, `RefreshWindowsCommand`,
- `CaptureWindowCommand`, `IsTransientCaptureKey`=`window:`+`picker:`, `IsAliveWindowPin`,
- `OnScreenCaptureFailed`).
-- Heals: `ReacquireScreenCaptures` strips transient keys before auto-detection (**session-scoped,
- HWND-recycle hazard**); `OnFullscreenMonitorChanged` respects a live window pin (**pin wins while
- alive**); `CaptureFailed` → auto re-acquire for transient keys (window death = no dead end).
-- Ctor order matters: `_windowEnumerator = WindowEnumeratorOverride ?? new Win32WindowEnumerator()`
- is assigned BEFORE `RefreshWindowsCommand`/`CaptureWindowCommand`/`RefreshOpenWindows()`.
-- LeftPanel row-menu bindings use Window-anchored `RelativeSource` (ContextMenu DataContext is the
- row Source, not the VM) — mirrors the existing PlacementTarget.Tag pattern.
-- **ONE Good Dog integration test** (`WindowBackdropIntegrationTests`): seam feeds the submenu; a
- persisted `window:` key heals away on reload; pinning an un-capturable window falls back to auto.
- NOTE: an earlier draft asserted the auto-fallback is non-empty — WRONG in the full suite, where
- ambient desktop state can legitimately yield `null` (static art). Final test accepts null as valid.
-- **Builds 0 warnings / 0 errors.** Full suite: **312/312 pass.** One wild-lasting detail: tests use
- the Windows dotnet host and MUST be rebuilt before vstest (stale-dll false failures).
-
-## Pricing decision (2026-09-21) — record, do not re-derive
-
-One-time perpetual license: **$29 founder** (launch→first 90 days) → **$49 list** at GA. Buy once,
-own forever; `IsPremium` never lapses (renewal/lapse code path dead). Free tier = full app,
-watermark-only. Driver = accessibility. Full reasoning in `MONETIZATION.md` (§Pricing, "Why these
-numbers"). $49 anchor, **$29 floor — never below**. Docs cascaded (unit A above).
+- `ChannelStatsService` (`channels.list statistics,auditDetails,contentDetails` +
+ `playlistItems.list`, virtual `CaptureCurrentAsync` = test seam); `YppTrackerViewModel` mirrors
+ `LiveBroadcastFormViewModel` (drawer state + commands); `MainViewModel.Ypp.cs` wires `Ypp`,
+ `ChannelStatsFactoryOverride`, and **one-open-at-a-time exclusivity** (both directions);
+ account hooks in `MainViewModel.Account.cs`.
+- `PreviewPane.xaml` — `TextPullOutHost` is now a drawer bank: [broadcast drawer][ypp drawer]
+ [stacked white-tab column]; same animated-width pattern (To=320, 0.2s CubicEase). Outside-click
+ collapse in `MainWindow.xaml.cs` covers both. `IsClickInsideDrawer` unchanged.
+- **Good Dog = ONE integration test** (`YppPullOutTests`): connected-VM snapshot capture +
+ persistence + checklist round-trip + drawer toggles; real-window exclusivity both ways.
## Next step
-1. User reviews/commits this work unit (code + `ai.md`/`TASKS.md`/`TASKS/task-38…`/`HANDOFF.md` in
- the SAME commit, per rules). **No push unless it's a milestone.**
-2. Then the pricing docs unit (5 dirty files) per `TASKS/task-10-monetization.md`.
-3. Then TASK 10 implementation (perpetual-key model — renewal/lapse removal).
+1. Close the running ytLive, rebuild in place, run full suite again (313/313).
+2. `./scripts/scope-check.sh ""` audit.
+3. User reviews/commits (code + memory in SAME commit, per rules). No push unless the user says.
+4. Then TASK 39 **slice 2** (Analytics scope re-consent → watch-hours/Shorts + velocity/ETA +
+ sparkline) per `TASKS/task-39-ypp-journey-tracker.md`.
## Critical working rules (unchanged, still binding)
- **Good Dog = ONE integration test per change.**
-- **Scope lock:** declare the exact file list BEFORE editing; run `scripts/scope-check.sh` with the
- declared list before commit. No "while I'm here" edits. (Exception this session: the PillRadioTests
- syntax-error fix — required before ANY test could compile; declared at the time.)
+- **Scope lock:** declare the exact file list before editing; run `scripts/scope-check.sh` before
+ commit. No "while I'm here" edits.
- **Run from WSL** always uses the Windows dotnet host (`/mnt/c/Program Files/dotnet/dotnet.exe`)
- with **quoted** paths; test filter runs via `dotnet vstest --filter …`.
-- **ai.md + code in the SAME commit**; rewrite HANDOFF.md at session end or interruption.
+ with quoted paths and `--TestCaseFilter` for filtered vstest runs.
+- **ai.md + code in the SAME commit**; rewrite HANDOFF.md at session end.
## Where the machine facts live
- `%APPDATA%\ytLive\ytLive.db` = real layout DB (`layout.db` is a 0-byte legacy file). sqlite3 at
- `/root/android-sdk/platform-tools/sqlite3`.
+ `/root/android-sdk/platform-tools/sqlite3`. Schema is now **user_version = 10**.
- ffmpeg/ffprobe: `/mnt/c/Program Files/Krita (x64)/bin/` with Windows paths.
-- `ai.md` §Local recording (TASK 18, ≈lines 605-640) = authoritative pill/state-model doc.
-- `ai.md` §Screen backdrop capture = authoritative backdrop/capture-key/heal doc (incl. the new
- TASK 38 bullet).
+- `ai.md` §Screen backdrop capture = authoritative backdrop/capture-key/heal doc (incl. TASK 38).
+- `ai.md` §Monetization awareness = authoritative monetization roadmap (incl. the TASK 39
+ journey-tracker state). YPP thresholds/date doubling live there + `Models/YppThresholds.cs`.
- Business/marcom facts: `MONETIZATION.md` + `MARCOM.md` (both gitignored).
\ No newline at end of file
diff --git a/MainWindow.xaml.cs b/MainWindow.xaml.cs
index cd92ca5..0e438b4 100644
--- a/MainWindow.xaml.cs
+++ b/MainWindow.xaml.cs
@@ -142,8 +142,12 @@ public partial class MainWindow : Window
{
if (e.OriginalSource is not DependencyObject original) return;
var form = _viewModel.BroadcastForm;
- if (form.IsDrawerOpen && !_previewPane.IsClickInsideDrawer(original))
- form.CloseDrawerCommand.Execute(null);
+ var ypp = _viewModel.Ypp;
+ if (!_previewPane.IsClickInsideDrawer(original))
+ {
+ if (form.IsDrawerOpen) form.CloseDrawerCommand.Execute(null);
+ if (ypp.IsDrawerOpen) ypp.CloseDrawerCommand.Execute(null);
+ }
if (_previewPane.IsClickInsidePreview(original)) return;
if (_leftPanel.IsClickInside(original)) return;
_viewModel.SelectedElement = null;
diff --git a/Models/YppStatSnapshot.cs b/Models/YppStatSnapshot.cs
new file mode 100644
index 0000000..32fbce8
--- /dev/null
+++ b/Models/YppStatSnapshot.cs
@@ -0,0 +1,17 @@
+namespace ytLive.Models;
+
+/// One captured snapshot of a connected channel's YPP-relevant numbers
+/// (TASK 39). Appended on every refresh and archived in SQLite so a later slice
+/// can compute velocity/(de)acceleration/ETA from the history.
+public sealed class YppStatSnapshot
+{
+ public DateTime CapturedAtUtc { get; set; }
+ public long SubscriberCount { get; set; }
+ public long VideoCount { get; set; }
+ public long TotalViews { get; set; }
+ public int UploadsLast90Days { get; set; }
+ public bool CommunityGuidelinesGoodStanding { get; set; }
+ public bool CopyrightStrikesGoodStanding { get; set; }
+ public bool ContentIdClaimsGoodStanding { get; set; }
+ public bool OverallGoodStanding { get; set; }
+}
\ No newline at end of file
diff --git a/Models/YppThresholds.cs b/Models/YppThresholds.cs
new file mode 100644
index 0000000..2bd58a9
--- /dev/null
+++ b/Models/YppThresholds.cs
@@ -0,0 +1,31 @@
+namespace ytLive.Models;
+
+/// YPP eligibility thresholds as versioned, date-aware DATA — never
+/// constants (ai.md "Journey tracker"). Tier 1 (fan funding) is time-invariant;
+/// Tier 2 (ad revenue) doubles its long-form-hours and Shorts-view bars for NEW
+/// applicants after 2027-02-01. Hardcoding the pre-2027 numbers would show the
+/// wrong ETA once the doubling lands, so callers resolve the active Tier 2
+/// threshold through .
+public static class YppThresholds
+{
+ public sealed record TierThreshold(string Name, long Subscribers, long WatchHours, long ShortsViews);
+
+ /// The date the Tier-2 ad-revenue bars double for new applicants.
+ public static readonly DateTime Tier2DoublingDate = new(2027, 2, 1);
+
+ /// Fan-funding tier (before the full ad/YouTube Premium revenue tier).
+ public static TierThreshold Tier1 { get; } = new("Fan funding", 500, 3_000, 3_000_000);
+
+ /// The Tier-2 bar for a given point in time — 4,000h / 10M Shorts
+ /// before the doubling date, 8,000h / 20M Shorts on or after it.
+ public static TierThreshold Tier2For(DateTime when) => when >= Tier2DoublingDate
+ ? new TierThreshold("Ad revenue", 1_000, 8_000, 20_000_000)
+ : new TierThreshold("Ad revenue", 1_000, 4_000, 10_000_000);
+
+ /// The currently-active Tier-2 threshold (resolved against today).
+ public static TierThreshold Tier2Now => Tier2For(DateTime.UtcNow);
+
+ /// Valid public long-form uploads required in the trailing 90 days
+ /// to keep each tier route eligible.
+ public const int UploadsRequiredPer90Days = 3;
+}
\ No newline at end of file
diff --git a/Services/ChannelStatsService.cs b/Services/ChannelStatsService.cs
new file mode 100644
index 0000000..4897ce9
--- /dev/null
+++ b/Services/ChannelStatsService.cs
@@ -0,0 +1,119 @@
+using System.Net.Http;
+using System.Text.Json;
+using ytLive.Models;
+using AppLog = ytLive.Helpers.AppLog;
+
+namespace ytLive.Services;
+
+///
+/// Pulls a connected channel's YPP-relevant numbers from the YouTube Data API
+/// (TASK 39, slice 1 — the current OAuth scopes suffice, no re-consent):
+/// subscriptions/views/videos + audit standing via channels.list, and the count
+/// of public uploads in the trailing 90 days via playlistItems.list on the
+/// uploads playlist. Long-form watch hours and Shorts-feed views need the
+/// Analytics API (a later slice behind the IChannelStatsProvider seam).
+///
+public class ChannelStatsService
+{
+ private readonly YouTubeAuthService _auth;
+ private readonly HttpClient _http;
+ private const string ApiBase = "https://www.googleapis.com/youtube/v3";
+
+ public ChannelStatsService(YouTubeAuthService auth, HttpClient? http = null)
+ {
+ _auth = auth;
+ _http = http ?? new HttpClient();
+ }
+
+ private async Task EnsureToken()
+ {
+ if (_auth.CurrentChannel == null) return false;
+ if (_auth.CurrentChannel.TokenExpiry <= DateTime.UtcNow.AddMinutes(5))
+ return await _auth.RefreshToken();
+ return true;
+ }
+
+ /// Captures the channel's current stats into a snapshot, or null when
+ /// there's no session, a request fails, or the API returns nothing. Never throws.
+ public virtual async Task CaptureCurrentAsync()
+ {
+ if (!await EnsureToken()) return null;
+
+ _http.DefaultRequestHeaders.Authorization = new("Bearer", _auth.CurrentChannel!.AccessToken);
+
+ var response = await _http.GetAsync(
+ $"{ApiBase}/channels?mine=true&part=statistics,auditDetails,contentDetails");
+ if (!response.IsSuccessStatusCode)
+ {
+ AppLog.Write($"YPP stats: channels.list failed ({(int)response.StatusCode})");
+ return null;
+ }
+
+ var json = JsonSerializer.Deserialize(await response.Content.ReadAsStringAsync());
+ if (!json.TryGetProperty("items", out var items) || items.GetArrayLength() == 0)
+ return null;
+
+ var channel = items[0];
+ var statistics = channel.GetProperty("statistics");
+
+ var uploadsPlaylistId = channel.TryGetProperty("contentDetails", out var contentDetails)
+ && contentDetails.TryGetProperty("relatedPlaylists", out var related)
+ && related.TryGetProperty("uploads", out var uploads)
+ ? uploads.GetString()
+ : null;
+
+ var uploadsLast90Days = uploadsPlaylistId != null
+ ? await CountUploadsLast90DaysAsync(uploadsPlaylistId)
+ : 0;
+
+ return new YppStatSnapshot
+ {
+ CapturedAtUtc = DateTime.UtcNow,
+ SubscriberCount = statistics.TryGetProperty("subscriberCount", out var subs) ? subs.GetInt64() : 0,
+ VideoCount = statistics.TryGetProperty("videoCount", out var videos) ? videos.GetInt64() : 0,
+ TotalViews = statistics.TryGetProperty("viewCount", out var views) ? views.GetInt64() : 0,
+ UploadsLast90Days = uploadsLast90Days,
+ CommunityGuidelinesGoodStanding = ReadStandingFlag(channel, "communityGuidelinesGoodStanding"),
+ CopyrightStrikesGoodStanding = ReadStandingFlag(channel, "copyrightStrikesGoodStanding"),
+ ContentIdClaimsGoodStanding = ReadStandingFlag(channel, "contentIdClaimsGoodStanding"),
+ OverallGoodStanding = ReadStandingFlag(channel, "overallGoodStanding"),
+ };
+ }
+
+ /// Counts uploads published within the trailing 90 days, scanning the
+ /// most recent 50 from the uploads playlist (generous beyond the 3/90-day YPP
+ /// requirement; the YPP tab labels it as the recent-50 window).
+ private async Task CountUploadsLast90DaysAsync(string uploadsPlaylistId)
+ {
+ var response = await _http.GetAsync(
+ $"{ApiBase}/playlistItems?part=snippet&playlistId={Uri.EscapeDataString(uploadsPlaylistId)}&maxResults=50");
+ if (!response.IsSuccessStatusCode)
+ {
+ AppLog.Write($"YPP stats: playlistItems.list failed ({(int)response.StatusCode})");
+ return 0;
+ }
+
+ var json = JsonSerializer.Deserialize(await response.Content.ReadAsStringAsync());
+ if (!json.TryGetProperty("items", out var items)) return 0;
+
+ var cutoff = DateTime.UtcNow.AddDays(-90);
+ var count = 0;
+ foreach (var item in items.EnumerateArray())
+ {
+ if (!item.TryGetProperty("snippet", out var snippet)
+ || !snippet.TryGetProperty("publishedAt", out var published)
+ || !DateTime.TryParse(published.GetString(), out var publishedAt))
+ {
+ continue;
+ }
+ if (publishedAt.ToUniversalTime() >= cutoff)
+ count++;
+ }
+ return count;
+ }
+
+ private static bool ReadStandingFlag(JsonElement channel, string property)
+ => channel.TryGetProperty("auditDetails", out var audit)
+ && audit.TryGetProperty(property, out var flag)
+ && flag.GetBoolean();
+}
\ No newline at end of file
diff --git a/Services/LayoutStore.Migrations.cs b/Services/LayoutStore.Migrations.cs
index c102e8f..b587abb 100644
--- a/Services/LayoutStore.Migrations.cs
+++ b/Services/LayoutStore.Migrations.cs
@@ -109,6 +109,22 @@ public partial class LayoutStore : IDisposable
Value TEXT NOT NULL
);
""",
+ // v9 → v10 (TASK 39): YPP journey-tracker snapshots, appended on every
+ // stats refresh. The history is the raw data a later analytics slice
+ // reads to compute velocity/(de)acceleration/ETA.
+ """
+ CREATE TABLE IF NOT EXISTS YppSnapshot (
+ CapturedAtUtc TEXT NOT NULL PRIMARY KEY,
+ SubscriberCount INTEGER NOT NULL,
+ VideoCount INTEGER NOT NULL,
+ TotalViews INTEGER NOT NULL,
+ UploadsLast90Days INTEGER NOT NULL,
+ CommunityGuidelinesGoodStanding INTEGER NOT NULL,
+ CopyrightStrikesGoodStanding INTEGER NOT NULL,
+ ContentIdClaimsGoodStanding INTEGER NOT NULL,
+ OverallGoodStanding INTEGER NOT NULL
+ );
+ """,
};
foreach (var sql in statements)
{
@@ -126,7 +142,7 @@ public partial class LayoutStore : IDisposable
MigrateToV3();
using (var cmd = _connection.CreateCommand())
{
- cmd.CommandText = "PRAGMA user_version = 9;";
+ cmd.CommandText = "PRAGMA user_version = 10;";
cmd.ExecuteNonQuery();
}
}
diff --git a/Services/LayoutStore.Ypp.cs b/Services/LayoutStore.Ypp.cs
new file mode 100644
index 0000000..a6f0261
--- /dev/null
+++ b/Services/LayoutStore.Ypp.cs
@@ -0,0 +1,90 @@
+using Microsoft.Data.Sqlite;
+using ytLive.Models;
+
+namespace ytLive.Services;
+
+public partial class LayoutStore : IDisposable
+{
+ // ─── YPP journey-tracker snapshots (TASK 39) ───
+
+ /// Appends one YPP stats snapshot (append-only history; velocity/ETA
+ /// read it in a later analytics slice).
+ public void SaveYppSnapshot(YppStatSnapshot snapshot)
+ {
+ using var cmd = _connection.CreateCommand();
+ cmd.CommandText = """
+ INSERT INTO YppSnapshot (CapturedAtUtc, SubscriberCount, VideoCount, TotalViews,
+ UploadsLast90Days, CommunityGuidelinesGoodStanding,
+ CopyrightStrikesGoodStanding, ContentIdClaimsGoodStanding,
+ OverallGoodStanding)
+ VALUES ($captured, $subs, $videos, $views, $uploads, $cg, $cs, $cic, $overall);
+ """;
+ cmd.Parameters.AddWithValue("$captured", snapshot.CapturedAtUtc.ToString("o"));
+ cmd.Parameters.AddWithValue("$subs", snapshot.SubscriberCount);
+ cmd.Parameters.AddWithValue("$videos", snapshot.VideoCount);
+ cmd.Parameters.AddWithValue("$views", snapshot.TotalViews);
+ cmd.Parameters.AddWithValue("$uploads", snapshot.UploadsLast90Days);
+ cmd.Parameters.AddWithValue("$cg", snapshot.CommunityGuidelinesGoodStanding ? 1 : 0);
+ cmd.Parameters.AddWithValue("$cs", snapshot.CopyrightStrikesGoodStanding ? 1 : 0);
+ cmd.Parameters.AddWithValue("$cic", snapshot.ContentIdClaimsGoodStanding ? 1 : 0);
+ cmd.Parameters.AddWithValue("$overall", snapshot.OverallGoodStanding ? 1 : 0);
+ cmd.ExecuteNonQuery();
+ }
+
+ /// Most recent snapshots, newest first (defaults to the full history).
+ public List LoadYppSnapshots(int limit = 365)
+ {
+ var result = new List();
+ using var cmd = _connection.CreateCommand();
+ cmd.CommandText = """
+ SELECT CapturedAtUtc, SubscriberCount, VideoCount, TotalViews, UploadsLast90Days,
+ CommunityGuidelinesGoodStanding, CopyrightStrikesGoodStanding,
+ ContentIdClaimsGoodStanding, OverallGoodStanding
+ FROM YppSnapshot ORDER BY CapturedAtUtc DESC LIMIT $limit;
+ """;
+ cmd.Parameters.AddWithValue("$limit", limit);
+ using var reader = cmd.ExecuteReader();
+ while (reader.Read())
+ {
+ result.Add(new YppStatSnapshot
+ {
+ CapturedAtUtc = DateTime.TryParse(reader.GetString(0), out var captured) ? captured : DateTime.MinValue,
+ SubscriberCount = reader.GetInt64(1),
+ VideoCount = reader.GetInt64(2),
+ TotalViews = reader.GetInt64(3),
+ UploadsLast90Days = reader.GetInt32(4),
+ CommunityGuidelinesGoodStanding = reader.GetInt32(5) != 0,
+ CopyrightStrikesGoodStanding = reader.GetInt32(6) != 0,
+ ContentIdClaimsGoodStanding = reader.GetInt32(7) != 0,
+ OverallGoodStanding = reader.GetInt32(8) != 0,
+ });
+ }
+ return result;
+ }
+
+ /// The manually self-reported YPP checklist state (2FA / AdSense are
+ /// not readable from any YouTube API — the creator ticks them in-app).
+ public (bool TwoFactorEnabled, bool AdSenseLinked) LoadYppChecklist()
+ {
+ return (GetSetting("Ypp.TwoFactorEnabled") == "1", GetSetting("Ypp.AdSenseLinked") == "1");
+ }
+
+ public void SaveYppChecklist(bool twoFactorEnabled, bool adsenseLinked)
+ {
+ UpsertSetting("Ypp.TwoFactorEnabled", twoFactorEnabled ? "1" : "0");
+ UpsertSetting("Ypp.AdSenseLinked", adsenseLinked ? "1" : "0");
+ }
+
+ /// UTC timestamp of the last successful stats refresh (used to gate
+ /// the once-daily auto-refresh against the analytics quota-thin API).
+ public DateTime? LoadYppLastRefreshedAtUtc()
+ {
+ var value = GetSetting("Ypp.LastRefreshedAtUtc");
+ return DateTime.TryParse(value, out var dt) ? dt : null;
+ }
+
+ public void SaveYppLastRefreshedAtUtc(DateTime utc)
+ {
+ UpsertSetting("Ypp.LastRefreshedAtUtc", utc.ToString("o"));
+ }
+}
\ No newline at end of file
diff --git a/Services/YppTrackerViewModel.cs b/Services/YppTrackerViewModel.cs
new file mode 100644
index 0000000..b817106
--- /dev/null
+++ b/Services/YppTrackerViewModel.cs
@@ -0,0 +1,303 @@
+using System.Diagnostics;
+using System.Windows.Input;
+using ytLive.Helpers;
+using ytLive.Models;
+
+namespace ytLive.Services;
+
+///
+/// Backing VM for the YPP pull-out (the "YPP" tab below Stream Settings on the
+/// Live screen's right edge). Shows the connected channel's current position
+/// toward the YPP tier thresholds (subscribers + 90-day uploads via the Data
+/// API), a compliance checklist (live standing from auditDetails + self-reported
+/// 2FA/AdSense), and the "what counts" education the creator would otherwise
+/// have to leave the app to find. Snapshots are appended to SQLite on every
+/// refresh so a later analytics slice can compute velocity/ETA from history.
+/// Drawer open/close lives here (mirrors LiveBroadcastFormViewModel).
+///
+public sealed class YppTrackerViewModel : ViewModelBase
+{
+ private const double BarMaxWidth = 220;
+
+ private readonly Func _store;
+ private readonly Func _stats;
+ private readonly Func _isConnected;
+ private readonly Func _displayName;
+
+ private bool _isDrawerOpen;
+ private bool _isLoading;
+ private bool _isRefreshing;
+ private string? _loadError;
+ private string _displayNameText = string.Empty;
+ private long _subscriberCount;
+ private long _videoCount;
+ private long _totalViews;
+ private int _uploadsLast90Days;
+ private bool _communityGuidelinesGoodStanding;
+ private bool _copyrightStrikesGoodStanding;
+ private bool _contentIdClaimsGoodStanding;
+ private bool _overallGoodStanding;
+ private bool _hasSnapshot;
+ private bool _twoFactorEnabled;
+ private bool _adsenseLinked;
+ private DateTime? _lastRefreshedAtUtc;
+
+ public YppTrackerViewModel(
+ Func store,
+ Func stats,
+ Func isConnected,
+ Func displayName)
+ {
+ _store = store;
+ _stats = stats;
+ _isConnected = isConnected;
+ _displayName = displayName;
+
+ RefreshCommand = new RelayCommand(_ => _ = RefreshAsync());
+ ToggleDrawerCommand = new RelayCommand(_ => IsDrawerOpen = !IsDrawerOpen);
+ CloseDrawerCommand = new RelayCommand(_ => IsDrawerOpen = false);
+ OpenEarnCommand = new RelayCommand(_ => OpenUrl(EarnUrl));
+ OpenTwoStepCommand = new RelayCommand(_ => OpenUrl(TwoStepVerificationUrl));
+ OpenAdsenseCommand = new RelayCommand(_ => OpenUrl(AdsenseGuideUrl));
+
+ (_twoFactorEnabled, _adsenseLinked) = _store().LoadYppChecklist();
+ _lastRefreshedAtUtc = _store().LoadYppLastRefreshedAtUtc();
+ }
+
+ // Deep links — the parts of the checklist YouTube does NOT expose to any API
+ // resolve in the browser; everything API-derived stays in-app.
+ private const string EarnUrl = "https://www.youtube.com/earn";
+ private const string TwoStepVerificationUrl = "https://myaccount.google.com/signinoptions/two-step-verification";
+ private const string AdsenseGuideUrl = "https://support.google.com/youtube/answer/72851";
+
+ public RelayCommand RefreshCommand { get; }
+ public RelayCommand ToggleDrawerCommand { get; }
+ public RelayCommand CloseDrawerCommand { get; }
+ public RelayCommand OpenEarnCommand { get; }
+ public RelayCommand OpenTwoStepCommand { get; }
+ public RelayCommand OpenAdsenseCommand { get; }
+
+ public bool IsDrawerOpen
+ {
+ get => _isDrawerOpen;
+ set
+ {
+ if (SetProperty(ref _isDrawerOpen, value) && value)
+ _ = EnsureLoadedAsync();
+ }
+ }
+
+ public bool IsLoading
+ {
+ get => _isLoading;
+ private set => SetProperty(ref _isLoading, value);
+ }
+
+ public bool IsSignedOut => !_isConnected();
+
+ public string? LoadError
+ {
+ get => _loadError;
+ private set => SetProperty(ref _loadError, value);
+ }
+
+ public string DisplayName => _displayNameText;
+
+ // ─── Live numbers (from the Data API snapshot) ───
+
+ public long SubscriberCount => _subscriberCount;
+ public long VideoCount => _videoCount;
+ public long TotalViews => _totalViews;
+ public int UploadsLast90Days => _uploadsLast90Days;
+ public bool CommunityGuidelinesGoodStanding => _communityGuidelinesGoodStanding;
+ public bool CopyrightStrikesGoodStanding => _copyrightStrikesGoodStanding;
+ public bool ContentIdClaimsGoodStanding => _contentIdClaimsGoodStanding;
+ public bool OverallGoodStanding => _overallGoodStanding;
+
+ /// True once a snapshot has successfully captured at least once this
+ /// session (drives the standing detail text's "sign in to load" state).
+ public bool HasSnapshot => _hasSnapshot;
+
+ // ─── Manual checklist (2FA / AdSense — no API exposes them) ───
+
+ public bool TwoFactorEnabled
+ {
+ get => _twoFactorEnabled;
+ set
+ {
+ if (SetProperty(ref _twoFactorEnabled, value))
+ PersistChecklist();
+ }
+ }
+
+ public bool AdSenseLinked
+ {
+ get => _adsenseLinked;
+ set
+ {
+ if (SetProperty(ref _adsenseLinked, value))
+ PersistChecklist();
+ }
+ }
+
+ // ─── Progress rows (subscribers is fixed; uploads is a 3/90d bar) ───
+
+ public double Tier1SubsProgress => Progress(SubscriberCount, YppThresholds.Tier1.Subscribers);
+ public double Tier2SubsProgress => Progress(SubscriberCount, YppThresholds.Tier2Now.Subscribers);
+ public double UploadsProgress => Progress(UploadsLast90Days, YppThresholds.UploadsRequiredPer90Days);
+
+ 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;
+
+ public string Tier1SubsText => $"{SubscriberCount:N0} / {YppThresholds.Tier1.Subscribers:N0}";
+ public string Tier2SubsText => $"{SubscriberCount:N0} / {YppThresholds.Tier2Now.Subscribers:N0}";
+ public string UploadsText => $"{UploadsLast90Days} / {YppThresholds.UploadsRequiredPer90Days} in the last 90 days";
+
+ public string StandingDetailText
+ {
+ get
+ {
+ if (!HasSnapshot) return "Sign in to load your channel's standing from YouTube.";
+ var flags = new System.Collections.Generic.List();
+ if (!CommunityGuidelinesGoodStanding) flags.Add("a community-guidelines flag");
+ if (!CopyrightStrikesGoodStanding) flags.Add("a copyright strike");
+ if (!ContentIdClaimsGoodStanding) flags.Add("a content-ID claim");
+ return flags.Count == 0
+ ? "Good standing — no live flags."
+ : $"FLAGGED ({string.Join(", ", flags)}). Check YouTube Studio for details.";
+ }
+ }
+
+ public string LastUpdatedText
+ {
+ get
+ {
+ if (_lastRefreshedAtUtc == null) return "Not refreshed yet";
+ var local = _lastRefreshedAtUtc.Value.ToLocalTime();
+ return local.Date == DateTime.Today
+ ? $"Updated today at {local:HH:mm}"
+ : $"Updated {local:g}";
+ }
+ }
+
+ public string WhatCountsText { get; } =
+ "Long-form watch hours: valid public VODs and livestreams watched in the trailing 12 " +
+ "months — Shorts-feed views don't count, and private, unlisted, deleted or ad-campaign " +
+ "time is excluded. Shorts views: valid public Shorts viewed in the Shorts feed over 90 " +
+ "days. Uploads: valid public long-form videos posted in the last 90 days. Zero active " +
+ "Community Guidelines strikes, 2-Step Verification switched on, and a linked AdSense " +
+ "account complete the checklist. Figures here are API-derived and close to — but not " +
+ "identical to — YouTube Studio's Earn tab.";
+
+ /// Account sign-in/out changed (fires from MainViewModel): reset the
+ /// signed-out state and refresh when a session now exists.
+ public void OnAccountChanged()
+ {
+ OnPropertyChanged(nameof(IsSignedOut));
+ OnPropertyChanged(nameof(DisplayName));
+ if (_hasSnapshot && !_isConnected())
+ {
+ _hasSnapshot = false;
+ OnPropertyChanged(nameof(HasSnapshot));
+ }
+ if (_isConnected())
+ _ = EnsureLoadedAsync();
+ }
+
+ /// Loads once per session/day (quota-honest: the analytics slice that
+ /// gives watch-hours/shorts has a thin targeted-query budget, so this gate is
+ /// baked in now). A manual "Refresh" always goes to the wire.
+ public async Task EnsureLoadedAsync()
+ {
+ if (!_isConnected() || _hasSnapshot) return;
+ var last = _store().LoadYppLastRefreshedAtUtc();
+ if (last != null && DateTime.UtcNow - last.Value < TimeSpan.FromHours(24)) return;
+ await RefreshAsync();
+ }
+
+ public async Task RefreshAsync()
+ {
+ if (_isRefreshing) return;
+ _isRefreshing = true;
+ IsLoading = true;
+ LoadError = null;
+ try
+ {
+ if (!_isConnected())
+ {
+ _lastRefreshedAtUtc = null;
+ OnPropertyChanged(nameof(LastUpdatedText));
+ return;
+ }
+
+ var snapshot = await _stats().CaptureCurrentAsync();
+ if (snapshot == null)
+ {
+ LoadError = "Couldn't reach YouTube — try again.";
+ return;
+ }
+
+ ApplySnapshot(snapshot);
+ _store().SaveYppSnapshot(snapshot);
+ _lastRefreshedAtUtc = snapshot.CapturedAtUtc;
+ _store().SaveYppLastRefreshedAtUtc(snapshot.CapturedAtUtc);
+ OnPropertyChanged(nameof(LastUpdatedText));
+ }
+ catch (Exception ex)
+ {
+ AppLog.Write($"YPP refresh failed: {ex}");
+ LoadError = "Refresh failed — try again.";
+ }
+ finally
+ {
+ _isRefreshing = false;
+ IsLoading = false;
+ }
+ }
+
+ private void ApplySnapshot(YppStatSnapshot snapshot)
+ {
+ _displayNameText = _displayName() ?? string.Empty;
+ _subscriberCount = snapshot.SubscriberCount;
+ _videoCount = snapshot.VideoCount;
+ _totalViews = snapshot.TotalViews;
+ _uploadsLast90Days = snapshot.UploadsLast90Days;
+ _communityGuidelinesGoodStanding = snapshot.CommunityGuidelinesGoodStanding;
+ _copyrightStrikesGoodStanding = snapshot.CopyrightStrikesGoodStanding;
+ _contentIdClaimsGoodStanding = snapshot.ContentIdClaimsGoodStanding;
+ _overallGoodStanding = snapshot.OverallGoodStanding;
+ _hasSnapshot = true;
+
+ OnPropertyChanged(nameof(DisplayName));
+ OnPropertyChanged(nameof(SubscriberCount));
+ OnPropertyChanged(nameof(VideoCount));
+ OnPropertyChanged(nameof(TotalViews));
+ OnPropertyChanged(nameof(UploadsLast90Days));
+ OnPropertyChanged(nameof(CommunityGuidelinesGoodStanding));
+ OnPropertyChanged(nameof(CopyrightStrikesGoodStanding));
+ OnPropertyChanged(nameof(ContentIdClaimsGoodStanding));
+ OnPropertyChanged(nameof(OverallGoodStanding));
+ OnPropertyChanged(nameof(HasSnapshot));
+ OnPropertyChanged(nameof(Tier1SubsProgress));
+ OnPropertyChanged(nameof(Tier2SubsProgress));
+ OnPropertyChanged(nameof(UploadsProgress));
+ OnPropertyChanged(nameof(Tier1SubsBarWidth));
+ OnPropertyChanged(nameof(Tier2SubsBarWidth));
+ OnPropertyChanged(nameof(UploadsBarWidth));
+ OnPropertyChanged(nameof(Tier1SubsText));
+ OnPropertyChanged(nameof(Tier2SubsText));
+ OnPropertyChanged(nameof(UploadsText));
+ OnPropertyChanged(nameof(StandingDetailText));
+ }
+
+ private void PersistChecklist() => _store().SaveYppChecklist(TwoFactorEnabled, AdSenseLinked);
+
+ private static double Progress(long current, long target)
+ => target <= 0 ? 0 : Math.Clamp((double)current / target, 0, 1);
+
+ private static void OpenUrl(string url)
+ {
+ Process.Start(new ProcessStartInfo(url) { UseShellExecute = true });
+ }
+}
\ No newline at end of file
diff --git a/TASKS.md b/TASKS.md
index 8b12bfd..b8d9a96 100644
--- a/TASKS.md
+++ b/TASKS.md
@@ -54,6 +54,7 @@
| 36 | Gold pass | ☐ Queued (2026-09-01) | [`TASKS/task-36-gold-pass.md`](TASKS/task-36-gold-pass.md) |
| 37 | Defaults vs current layout split | ☐ Queued (2026-09-20) | [`TASKS/task-37-defaults-current-split.md`](TASKS/task-37-defaults-current-split.md) |
| 38 | Capture Window… backdrop (in-app window picker) | ✅ Done — shipped 2026-09-21 | [`TASKS/task-38-window-backdrop.md`](TASKS/task-38-window-backdrop.md) |
+| 39 | YPP journey tracker (slice 1: current-scope data) | 🔶 Slice 1 ✅ — 2026-09-22; slice 2 (Analytics + ETA) queued | [`TASKS/task-39-ypp-journey-tracker.md`](TASKS/task-39-ypp-journey-tracker.md) |
---
diff --git a/TASKS/task-39-ypp-journey-tracker.md b/TASKS/task-39-ypp-journey-tracker.md
new file mode 100644
index 0000000..43673cb
--- /dev/null
+++ b/TASKS/task-39-ypp-journey-tracker.md
@@ -0,0 +1,84 @@
+# TASK 39 — YPP journey tracker (slice 1: current-scope data)
+
+**Status:** ✅ Slice 1 Done — shipped 2026-09-22 (Good Dog: ONE integration test)
+
+## Idea (creator ruling 2026-09-21, feasibility signed off 2026-09-22)
+
+Track the connected creator's YouTube Partner Program progress inside the app: a
+**"YPP"** tab on the Live screen's right edge, just below **Stream Settings**, sliding out the
+same way. Everything the creator needs about YPP eligibility and their position ships in-app —
+no leaving the app for definitions or numbers. The user also holds the YPP ruleset (studio
+"Earn" tab): 500 subs / 3 valid public uploads-90d for fan funding; 1,000 subs for full ad /
+Premium revenue; valid public long-form watch hours over trailing 12 months; valid public
+Shorts views (Shorts-feed origin) over 90 days; 0 active community-guidelines strikes; 2FA on;
+linked AdSense; **long-form bar doubles to 8,000h on 2027-02-01** (see ai.md "Journey tracker").
+
+**Slice split (user chose slice-1-then-slice-2):** slice 1 uses ONLY the current OAuth scopes
+— no re-consent; slice 2 adds the Analytics API (`yt-analytics.readonly`, one-time Google
+re-consent) behind the reserved `IChannelStatsProvider` seam for watch-hours/Shorts-views +
+velocity/ETA. Slice 1 still writes SQLite snapshots on every refresh, so slice 2 has history
+from day one.
+
+## Honest limits (told to the user before building)
+
+- No public API exposes the Earn-tab counters or the "valid public" filtering. Numbers are
+ **API-derived and labeled close-to-but-not-identical to Studio**.
+- **2FA and AdSense linkage are not readable from ANY YouTube API** → in-app self-reported
+ checkboxes with deep links (Google's 2-Step page / the Earn page). Standing IS readable from
+ `channels.list auditDetails` (overall + the three sub-flags) — live, no re-consent.
+- `subscriberCount` is rounded to 3 significant figures by Google.
+
+## What shipped
+
+- `Models/YppThresholds.cs` — thresholds as **versioned date-aware data** (never constants, per
+ ai.md): Tier1 (500 / 3,000h / 3M Shorts), `Tier2For(when)` returns 4,000h/10M before
+ **2027-02-01** and 8,000h/20M from then; `UploadsRequiredPer90Days = 3`.
+- `Models/YppStatSnapshot.cs` + `Services/LayoutStore.Ypp.cs` + schema **v9 → v10**
+ (`YppSnapshot` table): append-only snapshot history; manual checklist (`Ypp.TwoFactorEnabled` /
+ `Ypp.AdSenseLinked`); last-refresh-UTC gate (the once-daily gate a quota-thin analytics slice
+ will need is baked in now).
+- `Services/ChannelStatsService.cs` — `channels.list?mine=true&part=statistics,auditDetails,
+ contentDetails` (subs/views/videos + uploads-playlist id + standing flags) then
+ `playlistItems.list` (recent 50) counts uploads in the trailing 90 days. Virtual
+ `CaptureCurrentAsync` = the test seam. No new scope.
+- `Services/YppTrackerViewModel.cs` — drawer state + `Toggle/CloseDrawerCommand` + `RefreshCommand`
+ (mirrors `LiveBroadcastFormViewModel`), plus: signed-out/loading/error states, tier progress
+ bars (subs Tier-1 & Tier-2, uploads), live standing checkbox + detail, manual 2FA/AdSense
+ checkboxes, `WhatCountsText` education, deep links, `EnsureLoadedAsync` (once/day gate) +
+ explicit refresh, `OnAccountChanged()` hook.
+- `ViewModels/MainViewModel.Ypp.cs` + ctor — `Ypp` property, `ChannelStatsFactoryOverride` test
+ seam (mirrors `CameraEnumeratorOverride`), and **one-open-at-a-time exclusivity** wired both
+ directions (opening YPP closes Stream Settings and vice-versa). Sign-in/sign-out/restored-session
+ hooks in `MainViewModel.Account.cs`.
+- UI (`Controls/PreviewPane.xaml`) — `TextPullOutHost` is now one drawer bank: the broadcast
+ drawer, then the YPP drawer, then a **stacked white-tab column** (Stream Settings above YPP).
+ Same animated-width pattern; only one open at a time. `MainWindow.xaml.cs` outside-click
+ collapse covers both drawers. `IsClickInsideDrawer` (PreviewPane.cs) unchanged — covers the rail.
+- ONE integration test — `ytLive.Tests/YppPullOutTests.cs` (RealApp + temp DB): (1) connected
+ VM captures a fake snapshot → progress props, snapshot persisted, checklist round-trip, drawer
+ toggles; (2) real MainWindow enforces drawer exclusivity both ways.
+- Slice-2 placeholder rows (long-form hours / Shorts views) render a clear "arrives with the
+ extended analytics update" state in the drawer.
+
+## Deep links (verified stable endpoints; the exact Studio "Earn" tab path is a slice-2 nicety)
+
+- Earn: `https://www.youtube.com/earn`
+- 2FA: `https://myaccount.google.com/signinoptions/two-step-verification`
+- AdSense: `https://support.google.com/youtube/answer/72851`
+
+## Slice 2 (outline, queued)
+
+`yt-analytics.readonly` (+ re-consent) → `YouTubeAnalyticsService` behind `IChannelStatsProvider`
+(`ai.md`): long-form `estimatedMinutesWatched` (12-mo), Shorts-feed views (90-d), then velocity /
+(de)acceleration / ETA from the `YppSnapshot` history + a sparkline. Own Good Dog landing + task
+doc; the once-daily refresh gate and history table are already in place.
+
+## Acceptance (slice 1)
+
+- [x] YPP white tab appears below Stream Settings on the Live screen right edge.
+- [x] Clicking it slides out the YPP drawer; opening one drawer closes the other; outside-click
+ collapses whichever is open.
+- [x] Signed out → "Sign in to YouTube…" hint. Signed in → subs (Tier 1 & Tier 2), uploads/90d,
+ live standing, manual 2FA/AdSense, education text, refresh + last-updated.
+- [x] Every refresh appends a snapshot to SQLite (history ready for slice 2).
+- [x] 0 warnings / 0 errors; **313/313 tests pass** (one new Good Dog integration test).
\ No newline at end of file
diff --git a/ViewModels/MainViewModel.Account.cs b/ViewModels/MainViewModel.Account.cs
index 8f7e858..f867e43 100644
--- a/ViewModels/MainViewModel.Account.cs
+++ b/ViewModels/MainViewModel.Account.cs
@@ -73,6 +73,7 @@ public partial class MainViewModel
IsConnected = true;
SyncConnectedAccount();
+ Ypp.OnAccountChanged();
}
catch (Exception ex)
{
@@ -103,6 +104,7 @@ public partial class MainViewModel
IsConnected = true;
SyncConnectedAccount();
+ Ypp.OnAccountChanged();
return channel;
}
catch (Exception ex)
@@ -120,6 +122,7 @@ public partial class MainViewModel
IsConnected = false;
SyncConnectedAccount();
NotifySocialsChanged();
+ Ypp.OnAccountChanged();
AppLog.Write("Socials: signed out of YouTube");
await Task.CompletedTask;
}
diff --git a/ViewModels/MainViewModel.Ypp.cs b/ViewModels/MainViewModel.Ypp.cs
new file mode 100644
index 0000000..876d9fd
--- /dev/null
+++ b/ViewModels/MainViewModel.Ypp.cs
@@ -0,0 +1,36 @@
+using ytLive.Services;
+
+namespace ytLive.ViewModels;
+
+public partial class MainViewModel
+{
+ /// Test seam (mirrors CameraEnumeratorOverride): tests inject a fake
+ /// stats service before constructing the VM so refresh never hits the network.
+ internal static Func? ChannelStatsFactoryOverride { get; set; }
+
+ /// The YPP pull-out backing VM — the drawer below Stream Settings
+ /// on the Live screen's right edge (TASK 39).
+ public Services.YppTrackerViewModel Ypp { get; private set; } = null!;
+
+ private void InitYpp()
+ {
+ Ypp = new YppTrackerViewModel(
+ () => _layoutStore,
+ () => ChannelStatsFactoryOverride?.Invoke() ?? new ChannelStatsService(_youtubeAuth),
+ () => IsConnected,
+ () => _youtubeAuth.CurrentChannel?.DisplayName);
+
+ // One drawer at a time: the two pull-outs share the preview's right edge,
+ // so opening one collapses the other (both directions).
+ BroadcastForm.PropertyChanged += (_, e) =>
+ {
+ if (e.PropertyName == nameof(LiveBroadcastFormViewModel.IsDrawerOpen) && BroadcastForm.IsDrawerOpen)
+ Ypp.IsDrawerOpen = false;
+ };
+ Ypp.PropertyChanged += (_, e) =>
+ {
+ if (e.PropertyName == nameof(YppTrackerViewModel.IsDrawerOpen) && Ypp.IsDrawerOpen)
+ BroadcastForm.IsDrawerOpen = false;
+ };
+ }
+}
\ No newline at end of file
diff --git a/ViewModels/MainViewModel.cs b/ViewModels/MainViewModel.cs
index 196f348..4f343a9 100644
--- a/ViewModels/MainViewModel.cs
+++ b/ViewModels/MainViewModel.cs
@@ -242,6 +242,7 @@ public partial class MainViewModel : ViewModelBase
() => _youtubeStream,
() => CurrentBroadcastId,
() => IsLive);
+ InitYpp();
// Restore the last picked mic so the mixer's first capture attaches to
// the same, already-vetted device (green dot) or reports it missing
diff --git a/ai.md b/ai.md
index 6aece2a..0b0c0bd 100644
--- a/ai.md
+++ b/ai.md
@@ -1439,16 +1439,21 @@ removal above). It has three layers, all free — this is the product's differen
reward totals (new members, Super Chat $, stickers $, gifts, milestones). This is the in-app
replacement for the emailed external "stream activity report" — the incumbents (Streamlabs et al.)
email near-useless post-stream summaries; we track the same data natively and maintain it.
-- **Journey tracker.** On sign-in/startup, pull current `channels.list?mine=true&part=statistics`
- (`subscriberCount`/`viewCount`/`videoCount` — the current OAuth scopes suffice). Compute position
- toward each YPP tier threshold and project an ETA curve from the creator's own
- per-stream/per-day velocity (subs + watch hours). **YPP thresholds are versioned, date-aware DATA,
- never constants** — the Tier-2 bar doubles for new applicants on **2027-02-01** (long-form 4,000 →
- 8,000 qualified hrs / 365d; Shorts 10M → 20M / 90d), while the Tier-1 fan-funding bar (500 subs /
- 3,000 hrs / 3M Shorts) is unchanged. Hardcoding the pre-2027 numbers would ship a wrong ETA.
- Analytics-API scopes (`yt-analytics.readonly`, additive `yt-analytics-monetary.readonly`) are an
- additive capability behind a seam (`IChannelStatsProvider`), not a v1 blocker — current-scope data
- is the v1 baseline; the Analytics scopes would require an OAuth re-consent, deferred.
+- **Journey tracker (TASK 39, slice 1 shipped 2026-09-22).** A **"YPP"** tab below the
+ Stream Settings pull-out (Live screen right edge) shows the connected creator's position
+ toward each YPP tier threshold. Slice 1 = current-scope data only: `channels.list?mine=true
+ &part=statistics,auditDetails,contentDetails` (subs/views/videos + uploads-playlist id +
+ live standing flags) + `playlistItems.list` for 90-day public uploads. Every refresh appends
+ a `YppSnapshot` to SQLite (schema v10) — the history a later slice needs for velocity/ETA.
+ **YPP thresholds are versioned, date-aware DATA, never constants** — the Tier-2 bar doubles
+ for new applicants on **2027-02-01** (long-form 4,000 → 8,000 qualified hrs / 365d; Shorts
+ 10M → 20M / 90d), while the Tier-1 fan-funding bar (500 subs / 3,000 hrs / 3M Shorts) is
+ unchanged. Hardcoding the pre-2027 numbers would ship the wrong ETA. Watch-hours / Shorts /
+ velocity / ETA are **slice 2**: Analytics-API scopes (`yt-analytics.readonly`, additive
+ `yt-analytics-monetary.readonly`) behind the `IChannelStatsProvider` seam (additive
+ capability, requires an OAuth re-consent). Compliance items no API exposes (2FA, AdSense
+ linkage) are in-app self-reported checkboxes with deep links; standing checks are live from
+ `auditDetails`. Slice 2's once-daily refresh gate + snapshot history were baked into slice 1.
**What was rejected:** always-on watermark (obscurable — replaced by the flash), hard stream-time
cutoffs (the worst dead end — a stream dying mid-broadcast reads as broken, and YouTube streams
diff --git a/ytLive.Tests/YppPullOutTests.cs b/ytLive.Tests/YppPullOutTests.cs
new file mode 100644
index 0000000..e3cd22a
--- /dev/null
+++ b/ytLive.Tests/YppPullOutTests.cs
@@ -0,0 +1,142 @@
+using System;
+using System.IO;
+using System.Threading.Tasks;
+using Microsoft.Data.Sqlite;
+using Xunit;
+using ytLive.Models;
+using ytLive.Services;
+using ytLive.ViewModels;
+
+namespace ytLive.Tests;
+
+///
+/// TASK 39 Good Dog: the YPP pull-out, one integration test, real-app + temp-DB
+/// pattern (mirrors BroadcastPullOutTests / WindowBackdropIntegrationTests).
+/// Two sections under one [Fact]: (1) a connected channel captures a snapshot
+/// through the VM — progress props populate, the snapshot persists to SQLite,
+/// the manual checklist round-trips the Settings store, and the drawer commands
+/// toggle; (2) a real MainWindow enforces one-open-at-a-time exclusivity between
+/// the Stream Settings and YPP drawers.
+///
+[Collection("RealApp")]
+public sealed class YppPullOutTests
+{
+ private readonly RealAppHost _app;
+
+ public YppPullOutTests(RealAppHost app) => _app = app;
+
+ /// Returns canned data with no network — the fake bypasses every API call.
+ private sealed class FakeChannelStats : ChannelStatsService
+ {
+ public FakeChannelStats(YppStatSnapshot snapshot)
+ : base(new YouTubeAuthService("test-client", "test-secret")) => _snapshot = snapshot;
+
+ private readonly YppStatSnapshot _snapshot;
+
+ public override Task CaptureCurrentAsync()
+ => Task.FromResult(_snapshot);
+ }
+
+ [Fact]
+ public void Ypp_LoadsSnapshot_PersistsChecklist_And_DrawerExcludesBroadcast()
+ {
+ _app.Run(Run);
+ }
+
+ private void Run()
+ {
+ var tempDb = Path.Combine(Path.GetTempPath(), $"ytLlive-ypp-{Guid.NewGuid():N}.db");
+ MainViewModel.LayoutPathOverride = tempDb;
+ MainWindow? window = default;
+ try
+ {
+ using (var schema = new LayoutStore(tempDb)) { }
+ SqliteConnection.ClearAllPools();
+
+ // ─── Section 1: connected VM captures + persists ───
+ var snapshot = new YppStatSnapshot
+ {
+ CapturedAtUtc = DateTime.UtcNow,
+ SubscriberCount = 350,
+ VideoCount = 42,
+ TotalViews = 12_345,
+ UploadsLast90Days = 2,
+ CommunityGuidelinesGoodStanding = true,
+ CopyrightStrikesGoodStanding = true,
+ ContentIdClaimsGoodStanding = true,
+ OverallGoodStanding = true,
+ };
+
+ var vm = new YppTrackerViewModel(
+ () => new LayoutStore(tempDb),
+ () => new FakeChannelStats(snapshot),
+ () => true,
+ () => "Test Channel");
+
+ vm.IsDrawerOpen = true;
+ Assert.True(vm.IsDrawerOpen);
+ vm.IsDrawerOpen = false;
+
+ vm.RefreshAsync().GetAwaiter().GetResult();
+ Assert.True(vm.HasSnapshot);
+ Assert.Equal(350, vm.SubscriberCount);
+ Assert.Equal(2, vm.UploadsLast90Days);
+ Assert.True(vm.OverallGoodStanding);
+ Assert.Equal("Test Channel", vm.DisplayName);
+ Assert.Equal("350 / 500", vm.Tier1SubsText);
+ Assert.Equal("350 / 1,000", vm.Tier2SubsText);
+ Assert.Equal(0.7, vm.Tier1SubsProgress, 3);
+ Assert.Equal(154, vm.Tier1SubsBarWidth, 0);
+ Assert.True(vm.Tier2SubsBarWidth < vm.Tier1SubsBarWidth);
+ Assert.StartsWith("Updated today", vm.LastUpdatedText);
+
+ // The snapshot persisted so a later analytics slice has history.
+ using (var reader = new LayoutStore(tempDb))
+ {
+ var history = reader.LoadYppSnapshots();
+ Assert.Single(history);
+ Assert.Equal(350, history[0].SubscriberCount);
+ Assert.Equal(2, history[0].UploadsLast90Days);
+ }
+
+ // The self-reported checklist round-trips the Settings store.
+ vm.TwoFactorEnabled = true;
+ vm.AdSenseLinked = false;
+ using (var reader = new LayoutStore(tempDb))
+ {
+ var (twoFactor, adsense) = reader.LoadYppChecklist();
+ Assert.True(twoFactor);
+ Assert.False(adsense);
+ }
+
+ // ─── Section 2: real window enforces one-open-at-a-time ───
+ window = new MainWindow();
+ var model = (MainViewModel)window.DataContext;
+ Assert.False(model.BroadcastForm.IsDrawerOpen);
+ Assert.False(model.Ypp.IsDrawerOpen);
+
+ model.Ypp.ToggleDrawerCommand.Execute(null);
+ Assert.True(model.Ypp.IsDrawerOpen);
+ Assert.False(model.BroadcastForm.IsDrawerOpen, "opening YPP must leave Stream Settings closed");
+
+ model.BroadcastForm.ToggleDrawerCommand.Execute(null);
+ Assert.True(model.BroadcastForm.IsDrawerOpen);
+ Assert.False(model.Ypp.IsDrawerOpen, "opening Stream Settings must close YPP");
+
+ model.BroadcastForm.ToggleDrawerCommand.Execute(null);
+ Assert.False(model.BroadcastForm.IsDrawerOpen);
+
+ model.Ypp.ToggleDrawerCommand.Execute(null);
+ Assert.True(model.Ypp.IsDrawerOpen);
+ model.Ypp.CloseDrawerCommand.Execute(null);
+ Assert.False(model.Ypp.IsDrawerOpen);
+ }
+ finally
+ {
+ window?.Close();
+ MainViewModel.LayoutPathOverride = null;
+ SqliteConnection.ClearAllPools();
+ try { File.Delete(tempDb); } catch { /* best-effort cleanup */ }
+ }
+ }
+}
\ No newline at end of file