feat(scoreboard): unit 10 — the polling policy (quiet by default)

ScoreboardCadence.Due(phase, lastAnalytics, lastSubscribers, now) is the creator's rule as a pure
function: watch hours ONLY during a live stream, every 30 minutes (Analytics answers in days and
test/record sessions have no public audience, so querying then is pure waste); subscribers whenever
the app is working, every 60 s; and nothing at all when idle.

Deliberately returns 'no poll' rather than 'analytics only' — a poll kind the poller cannot perform
is a bug waiting for a caller.

Remaining for the scoreboard: the HTTP providers behind this policy (watch hours via Analytics
reports.query, subscribers via channels.list) and the timer that calls Due(). Recorded in HANDOFF.
This commit is contained in:
2026-10-30 14:43:08 -07:00
parent 9a6099af7c
commit b205ce9ba9
2 changed files with 86 additions and 0 deletions
+47
View File
@@ -0,0 +1,47 @@
using System;
namespace ytLive.Models;
/// <summary>What kind of session the app is in, for polling decisions.</summary>
public enum SessionPhase
{
Idle,
Live,
Test,
Recording,
}
/// <summary>Which channel figures a poll should fetch.</summary>
public enum ScoreboardPoll
{
None,
SubscribersOnly,
Both,
}
/// <summary>Polling policy for the scoreboard's two channel figures (creator ruling 2026-10-30).
///
/// <para>Analytics answers in DAYS, and test/record sessions have no public audience — so querying
/// watch hours then is pure waste. Subscriber count is cheap (one quota unit) and moves during any
/// session, so it polls whenever the app is working. Idle means silent.</para></summary>
public static class ScoreboardCadence
{
public static readonly TimeSpan AnalyticsInterval = TimeSpan.FromMinutes(30);
public static readonly TimeSpan SubscriberInterval = TimeSpan.FromSeconds(60);
public static ScoreboardPoll Due(SessionPhase phase, DateTime lastAnalyticsUtc, DateTime lastSubscriberUtc, DateTime nowUtc)
{
if (phase == SessionPhase.Idle) return ScoreboardPoll.None;
var analyticsDue = phase == SessionPhase.Live && nowUtc - lastAnalyticsUtc >= AnalyticsInterval;
var subscribersDue = nowUtc - lastSubscriberUtc >= SubscriberInterval;
return (analyticsDue, subscribersDue) switch
{
(true, true) => ScoreboardPoll.Both,
(true, false) => ScoreboardPoll.None, // analytics alone is not a poll kind
(false, true) => ScoreboardPoll.SubscribersOnly,
_ => ScoreboardPoll.None,
};
}
}
+39
View File
@@ -0,0 +1,39 @@
using System;
using Xunit;
using ytLive.Models;
namespace ytLive.Tests;
/// <summary>The quiet-by-default rule: watch hours only during a live stream (Analytics is a daily
/// figure and test/record sessions have no public audience), subscribers whenever the app is
/// working, and silence when idle.</summary>
public class ScoreboardCadenceTests
{
[Fact]
public void Analytics_polls_only_when_live_and_subscribers_poll_during_any_session()
{
var now = new DateTime(2026, 10, 30, 12, 0, 0, DateTimeKind.Utc);
var old = now.AddHours(-2);
// Idle: silent, even if everything is stale.
Assert.Equal(ScoreboardPoll.None, ScoreboardCadence.Due(SessionPhase.Idle, old, old, now));
// Live with stale figures: both.
Assert.Equal(ScoreboardPoll.Both, ScoreboardCadence.Due(SessionPhase.Live, old, old, now));
// Test / recording: subscribers only — no public audience, so no analytics.
Assert.Equal(ScoreboardPoll.SubscribersOnly, ScoreboardCadence.Due(SessionPhase.Test, old, old, now));
Assert.Equal(ScoreboardPoll.SubscribersOnly, ScoreboardCadence.Due(SessionPhase.Recording, old, old, now));
// Fresh figures: nothing is due.
Assert.Equal(ScoreboardPoll.None, ScoreboardCadence.Due(SessionPhase.Live, now, now, now));
// Analytics alone never polls (it is not a kind the poller knows).
Assert.Equal(ScoreboardPoll.None,
ScoreboardCadence.Due(SessionPhase.Live, old, now.AddSeconds(-10), now));
// Subscribers alone mid-window.
Assert.Equal(ScoreboardPoll.SubscribersOnly,
ScoreboardCadence.Due(SessionPhase.Live, now, old, now));
}
}