Bridge Training

Usage analytics

Serves, daily play rollups, the rollup boundary between rolled-up days and live telemetry, perceived difficulty, and the usage stats and analytics read by the teacher app.

The usage module counts what the player did with a teacher's deals. A serve is one deal resolved by the public API; it counts once the player's first telemetry event lands it. Plays, results and graded difficulty are rolled up per owner, deal code and UTC day by a nightly job, and every usage read sums rollups up to the job's watermark then counts raw telemetry live after it. The API records serves, the jobs runtime runs the rollups, the standings and the perseverance snapshots, and the teacher app reads the usage tile and the detail view.

Import from @workspace/training/domain/usage and @workspace/training/application/usage. Reads reuse UsagePeriod from @workspace/training/domain/entitlements.

Domain

Perceived difficulty

The perceived difficulty of one play is a 0 to 100 index from the stars earned, the auction errors n and the minutes of card play t. k = 10 × (n + t − 2) grades the 2- and 3-star plays: 0 and 1 star score 100, 2 stars score 50 + k clamped to [50, 100], 3 stars score k clamped to [0, 50]. A play whose auction was never the learner's to bid counts no error. Plays longer than the outlier bound leave the aggregate.

Prop

Type

ExportDefinition
DIFFICULTY_MAX_INDEX100, the top of the scale.
DIFFICULTY_OUTLIER_SECONDS900: plays longer than 15 minutes are outliers.
FunctionSignatureDescription
derivePerceivedDifficulty(input: { stars: 0 | 1 | 2 | 3; auctionErrors: number; playSeconds: number }) => numberThe index of one play.
parseDifficultyStars(value: number) => 0 | 1 | 2 | 3 | nullNull when the value is not a play verdict.
isDifficultyOutlier(playSeconds: number) => booleanStrictly longer than DIFFICULTY_OUTLIER_SECONDS.
aggregatePerceivedDifficulty(plays: readonly PerceivedDifficultyPlay[]) => { difficultySum: number; gradedCount: number }Sums the graded plays, dropping outliers and anything without a verdict. A null auctionErrors counts no error, a null playSeconds grades as instant. The mean index is difficultySum / gradedCount.
usage-analytics.ts
derivePerceivedDifficulty({ stars: 1, auctionErrors: 0, playSeconds: 30 }); // => 100
derivePerceivedDifficulty({ stars: 2, auctionErrors: 1, playSeconds: 120 }); // => 60 (50 + 10 × (1 + 2 − 2))
derivePerceivedDifficulty({ stars: 3, auctionErrors: 0, playSeconds: 240 }); // => 20 (10 × (0 + 4 − 2))
derivePerceivedDifficulty({ stars: 3, auctionErrors: 0, playSeconds: 60 }); // => 0 (floored)

isDifficultyOutlier(DIFFICULTY_OUTLIER_SECONDS + 1); // => true: longer than 15 minutes
parseDifficultyStars(4); // => null: not a verdict

aggregatePerceivedDifficulty([
  { stars: 3, auctionErrors: null, playSeconds: 240 }, // auction never bid: counts no error => 20
  { stars: 2, auctionErrors: 1, playSeconds: null }, // never finished: graded as instant => 50
  { stars: 3, auctionErrors: 0, playSeconds: 20 * 60 }, // outlier, dropped
  { stars: 5, auctionErrors: 0, playSeconds: 10 }, // not a verdict, dropped
]); // => { difficultySum: 70, gradedCount: 2 }

Analytics windows

The detail view reads over a rolling window that always ends with today, half-open so the current UTC day is included, unlike the calendar-month entitlement period.

ExportDefinition
USAGE_ANALYTICS_PERIODS["last7", "last30", "season"]
DEFAULT_USAGE_ANALYTICS_PERIOD"last7"
USAGE_ANALYTICS_SCOPE_KINDS["session", "collection"]: what the analytics can be narrowed to.
UsageAnalyticsScope{ kind: UsageAnalyticsScopeKind; id: string }
USAGE_ANALYTICS_TOP_DEALS5, deals kept in the top-deals ranking of a window.
FunctionSignatureDescription
getSeasonStart(now: Date) => DateBridge seasons open on 1 July: this year's from July on, last year's before.
resolveUsageAnalyticsWindow(period: UsageAnalyticsPeriod, now: Date) => UsagePeriodHalf-open UTC window of a preset, ending at the start of tomorrow. last7 and last30 count today as their last day.
usage-analytics.ts
const now = new Date("2026-10-08T15:30:00Z");

resolveUsageAnalyticsWindow("last7", now);
// => { start: 2026-10-02T00:00:00Z, end: 2026-10-09T00:00:00Z } (today included, half-open)
resolveUsageAnalyticsWindow("season", now).start; // => 2026-07-01T00:00:00Z
getSeasonStart(new Date("2026-03-01T00:00:00Z")); // => 2025-07-01T00:00:00Z

Application

ServesService

createServesService(deps: ServesServiceDeps): ServesService, with deps = { serves: ServesRepository }

Records a serve for every deal resolved by the public API, the anchor for telemetry and billing. No actor context: the resolve path is public. The best-effort policy belongs to the caller; repository failures propagate.

MethodSignatureDescription
recordServe(input: PersistableServeWrite) => Promise<{ id: string }>The id is carried to the client as serveId.

Prop

Type

usage-analytics.ts
// Public resolve path: no ServiceContext, the deal may be anonymous.
export function mintServe(serves: ServesRepository, code: string) {
  const service = createServesService({ serves });
  return service.recordServe({
    sessionId: null, // SSR bootstrap: no client session yet
    code,
    boardId: null,
    ownerAccountId: null,
    diffusionDecidedAt: null,
    adsSponsorshipId: null,
    viewerAccountId: null,
    playerAccountId: null, // an ad_free player would pay the serve instead
  }); // => { id }: carried to the player as `serveId`
}

UsageStatsService

createUsageStatsService(deps: UsageStatsServiceDeps): UsageStatsService

Read-only view of the subject's current-period play activity and diffusions_per_period entitlement, for the usage tile. Not an enforcement path: quota debit happens at action time, never here. The read is about one grid of plans, the one the asking app sells: the period shown is the quota month of that grid's active subscription grant, else the UTC calendar month. quotaUsed and diffusionsLimit add up the counters and caps of every active grant whatever its grid, since the debit draws on all of them. The ad-served count uses the period's exact bounds; the play counts come from daily rollups, so a window opening mid-day counts that whole UTC day.

MethodSignatureDescription
getForSubject(subjectId: string, gridKey: string) => Promise<UsageStats>Throws NotFoundError (PLAN_GRID_NOT_FOUND) when no grid has the key. Resolves the rollup boundary before the counts, so a run landing mid-read can only move the boundary back on the next read, never double-count.

Prop

Type

Prop

Type

usage-analytics.ts
export async function usageTile(deps: UsageStatsServiceDeps, subjectId: string) {
  const stats = createUsageStatsService(deps);
  try {
    const usage = await stats.getForSubject(subjectId, "teachers");
    usage.servesCount; // rolled-up days + live count since the watermark
    usage.diffusionsLimit; // Number.POSITIVE_INFINITY when unlimited
    usage.ranking; // null until the daily job has placed the subject
    return usage;
  } catch (error) {
    if (isAppError(error)) {
      error.code; // => "PLAN_GRID_NOT_FOUND"
    }
    throw error;
  }
}

UsageAnalyticsService

createUsageAnalyticsService(deps: UsageAnalyticsServiceDeps): UsageAnalyticsService

Read-only detail view of the subject's authored deals over a rolling window: serves per UTC day and the most-served deals, each with its mean stars, its serves per student and its perceived difficulty. Same split as the usage stats: rollups up to the watermark, live telemetry after it, so the curve never shows a hole. The ranking is cut by the read over both spans at once, and the per-deal metrics are then read for its head alone, never for the whole catalogue. Perseverance is the exception: a distinct count of students cannot be summed across days, so it is read from the snapshot the nightly job precomputes at the three preset windows, fresh up to the last closed UTC day.

The view is a plan feature, usage_analytics: a subject without it is refused before anything of its activity is read. A session or a collection narrows the whole view to its deals and numbers them by their slot in it; a scope the subject does not own reads exactly like an unknown one.

MethodSignatureDescription
getForSubject(subjectId: string, query: UsageAnalyticsQuery) => Promise<UsageAnalytics>Throws FeatureDisabledError (FEATURE_DISABLED) without the feature, NotFoundError (GROUP_SESSION_NOT_FOUND or DEAL_COLLECTION_NOT_FOUND) when the scope resolves to no deal. Skips the per-deal reads entirely when nothing was served.

Prop

Type

Prop

Type

Prop

Type

Prop

Type

Prop

Type

usage-analytics.ts
export async function usageDetail(
  deps: UsageAnalyticsServiceDeps,
  subjectId: string,
  sessionId: string
) {
  const analytics = createUsageAnalyticsService(deps);
  try {
    const detail = await analytics.getForSubject(subjectId, {
      period: "last30",
      scope: { kind: "session", id: sessionId }, // only that session's deals, numbered by slot
    });
    detail.daily; // one zero-filled point per UTC day, today included
    detail.topDeals; // at most USAGE_ANALYTICS_TOP_DEALS, with meanStars, servesPerStudent, scopePosition
    detail.difficulty; // same deals, ungraded ones dropped
    return detail;
  } catch (error) {
    if (error instanceof FeatureDisabledError) {
      error.code; // => "FEATURE_DISABLED": the subject's plan lacks `usage_analytics`
    } else if (isAppError(error)) {
      error.code; // => "GROUP_SESSION_NOT_FOUND" (unknown, foreign or empty session)
    }
    throw error;
  }
}

Rollup boundary

Where a usage read stops summing daily rollups and starts counting raw telemetry. The cut is a watermark, not the calendar: the first UTC day of the window no execution ever covered. The last covered day would jump over a hole and a middle day the job missed would vanish from every figure; the first uncovered one hands that day and everything after it to the live count. Today is never covered, so the watermark is at most today, and a healthy job leaves the boundary exactly where the calendar cut used to be. Both sides slice time the same way (first_event_at for serves, received_at for events), so nothing is counted twice, and the live side has no upper bound: an outage widens it, it never drops a day.

Prop

Type

FunctionSignatureDescription
resolveRollupBoundary(coverage: PlayRollupRunsReadRepository, fromDay: string, now: Date) => Promise<RollupBoundary>Asks the coverage trace for [fromDay, today) and cuts at the first uncovered closed day, or today. A window opening today is read entirely live without a coverage read. Throws a plain Error for a fromDay that is not a UTC calendar day.
usage-analytics.ts
export async function whereLiveStarts(coverage: PlayRollupRunsReadRepository) {
  const boundary = await resolveRollupBoundary(coverage, "2026-10-01", now);
  // Every closed day covered   => rolledUp: 2026-10-01 → 2026-10-08, liveSince: 2026-10-08T00:00Z
  // 2026-10-04 never computed  => rolledUp: 2026-10-01 → 2026-10-04, liveSince: 2026-10-04T00:00Z
  return boundary;
}

PlayRollupsService

createPlayRollupsService(deps: PlayRollupsServiceDeps): PlayRollupsService

Computes one UTC day of the daily usage rollups from serves and telemetry events, and replaces that day's rows atomically. Idempotent: re-running a day over the same source rows yields identical output. The perceived difficulty of a play is graded here by the domain rule and stored as a sum plus a count. Every execution leaves a trace, so a day with no activity never reads like a day the job never reached.

MethodSignatureDescription
rollupDay(day: string, source: PlayRollupRunSource) => Promise<{ day: string; rows: number }>Reads the four count sets of the day window in parallel, merges them, replaces the day with the run trace. On failure, traces the failed run through runs with the root cause first and re-throws. An invalid day is rejected before any repository call and traces nothing.
findMissingDays(range: UtcDayRange, now: Date) => Promise<string[]>Closed days of the range no execution ever covered, oldest first. Today is still open, so it was never due.
summarizeStoredDays(range: UtcDayRange) => Promise<StoredPlayRollupRows>The rows the range holds, anonymous deals included, and how many carry graded results: what a re-roll of those days would replace. Writes nothing. The dry run of pnpm cli backfill-play-rollups.

The nightly job (apps/api/src/worker/rollup-daily.ts) and the backfill-play-rollups command of apps/cli call the same rollupDay; the command passes "backfill" as the source. StoredPlayRollupRows is { rows: number; graded: number }; the service reads it through the stored dependency, a PlayRollupsReadRepository.

Prop

Type

DEFAULT_ROLLUP_TOPICS is { rollupDaily: "telemetry.rollup-daily" }.

FunctionSignatureDescription
utcDayWindow(day: string) => UtcDayWindowHalf-open [00:00, 24:00) UTC window for a YYYY-MM-DD day; throws on anything else.
previousUtcDay(now: Date) => stringThe last closed UTC day.
lookbackDayRange(now: Date, days: number) => UtcDayRangeThe days closed UTC days preceding now; throws when days < 1.
uncoveredDays(range: UtcDayRange, covered: ReadonlySet<string>, today: string) => string[]Closed days of the range missing from covered, oldest first; today and later are never due.
mergeDailyCounts(day: string, serves: ServeDailyCount[], playEvents: PlayEventDailyCount[], resultStars: ResultStarsDailyCount[], difficultySamples: DifficultySample[]) => PlayRollupRow[]Full outer merge of the count sets keyed by (ownerAccountId, code), zero-filling missing sides, a null owner kept distinct from any account. Difficulty samples are graded per key.
usage-analytics.ts
utcDayWindow("2026-10-07"); // => { start: 2026-10-07T00:00:00Z, end: 2026-10-08T00:00:00Z }
previousUtcDay(now); // => "2026-10-07"
lookbackDayRange(now, 3); // => { fromDay: "2026-10-05", toDayExclusive: "2026-10-08" }
uncoveredDays(
  { fromDay: "2026-10-05", toDayExclusive: "2026-10-09" },
  new Set(["2026-10-05", "2026-10-07"]),
  "2026-10-08"
); // => ["2026-10-06"]: today and later are never due

DEFAULT_ROLLUP_TOPICS.rollupDaily; // => "telemetry.rollup-daily"

export async function nightlyRollup(deps: PlayRollupsServiceDeps, lookbackDays: number) {
  const rollups = createPlayRollupsService(deps);
  const today = new Date();
  // Yesterday first, then any closed day a previous run never covered.
  await rollups.rollupDay(previousUtcDay(today), "cron"); // => { day, rows }
  for (const day of await rollups.findMissingDays(lookbackDayRange(today, lookbackDays), today)) {
    await rollups.rollupDay(day, "retry");
  }
}

UsagePerseveranceWindowsService

createUsagePerseveranceWindowsService(deps: UsagePerseveranceWindowsServiceDeps): UsagePerseveranceWindowsService

Precomputes distinct-student counts per owner, deal and window for the three presets. One atomic replacement per day, so readers never see a partial table. Coverage ends at the last closed UTC day: the current day is excluded, so a serve on today is never double-counted between two runs.

MethodSignatureDescription
recomputeAll(now: Date) => Promise<{ throughDay: string; rows: number }>Resolves the windows, counts them in one scan, replaces the snapshot. Safe to re-run.
previewAll(now: Date) => Promise<{ throughDay: string; windows: Record<UsageAnalyticsPeriod, UsagePeriod>; rows: number }>The windows recomputeAll would count and how many rows it would write; writes nothing. The dry run of pnpm cli recompute-perseverance-windows.
FunctionSignatureDescription
resolvePerseveranceWindows(now: Date) => Record<UsageAnalyticsPeriod, UsagePeriod>Windows ending at the last closed UTC day, resolved by the same rule as the read side applied to yesterday.

OwnerPeriodActivityService

createOwnerPeriodActivityService(deps: OwnerPeriodActivityServiceDeps): OwnerPeriodActivityService

Ranks every active owner by serves over the current period and replaces that period's standings. Ranking is a cross-account scan, so it runs once a day here and no read path performs it. Runs after the daily rollup and only over closed days, so a replay the same day converges on the same rows.

MethodSignatureDescription
rankPeriodToDate(now: Date) => Promise<{ periodStart: string; owners: number }>Sums the rolled-up serves per owner from the period's first day to today exclusive, ranks them, and replaces the period with the standings, the active account count and the last closed day.
previewPeriodToDate(now: Date) => Promise<{ range: UtcDayRange; owners: number }>The closed days rankPeriodToDate would rank and how many owners it would place; writes nothing. The dry run of pnpm cli rank-period-standings.
FunctionSignatureDescription
rankOwnersByServes(counts: OwnerServesCount[]) => OwnerPeriodStanding[]Serves descending, ties sharing a rank (one plus the owners strictly ahead), position dense and unique. Owners without a serve are not ranked. Pure: leaves its input untouched.
usage-analytics.ts
rankOwnersByServes([
  { ownerAccountId: "b", served: 10 },
  { ownerAccountId: "a", served: 10 },
  { ownerAccountId: "c", served: 4 },
  { ownerAccountId: "d", served: 0 },
]);
// => [ { a, served 10, rank 1, position 1 }, { b, served 10, rank 1, position 2 },
//      { c, served 4, rank 3, position 3 } ]: owners without a serve are not ranked

resolvePerseveranceWindows(now).last7;
// => { start: 2026-10-01T00:00:00Z, end: 2026-10-08T00:00:00Z }: ends at the last closed day

export async function nightlyJobs(
  activity: OwnerPeriodActivityServiceDeps,
  perseverance: UsagePerseveranceWindowsServiceDeps
) {
  const standings = createOwnerPeriodActivityService(activity);
  const windows = createUsagePerseveranceWindowsService(perseverance);
  const today = new Date();
  await standings.previewPeriodToDate(today); // => { range, owners }: writes nothing
  await standings.rankPeriodToDate(today); // => { periodStart, owners }
  await windows.recomputeAll(today); // => { throughDay, rows }
}

Ports

The ports split along the tables. The read model the job writes, the rollups, their execution trace and the perseverance snapshot, is read through canonical repositories; the serves are written through one. The jobs that build a snapshot from raw telemetry and the reads that assemble one result from several tables keep a bespoke surface, ratified in Bespoke by design.

ServesRepository: append-only, writeOne only.

MethodSignatureDescription
writeOne(input: PersistableServeWrite) => Promise<{ serveId: string }>One serve per deal resolved; the player's first telemetry event later stamps it displayed.

PlayRollupsReadRepository: aggregate over the daily rollups, scoped by a PlayRollupCriteria.

ExportDefinition
PlayRollupWhere{ ownerAccountId?: Equality<string> & Nullability; code?: Equality<string>; days?: UtcDayRange }. isNull: false drops the anonymous editor deals.
PlayRollupCriteriaStatic ofOwner(ownerAccountId, range), ownedWithin(range), within(range) (anonymous deals included), where; instance withCodes(codes), a no-op on undefined, and and.
PlayRollupAggregationsservesByOwner(criteria) → OwnerServesCount[], servesByDay(criteria) → UsageDailyServes[] oldest first, metricsByCode(criteria) → UsageDealRollupMetrics[]: star and difficulty sums read together, since they share the key; storedRows(criteria) → StoredPlayRollupRows, the rows counted and the ones with resultsCount > 0.
usage-analytics.ts
export async function rolledUpCurve(
  rollups: PlayRollupsReadRepository,
  ownerAccountId: string,
  sessionCodes: string[] | undefined
) {
  const scope = PlayRollupCriteria.ofOwner(ownerAccountId, {
    fromDay: "2026-10-01",
    toDayExclusive: "2026-10-08",
  }).withCodes(sessionCodes); // undefined keeps every deal of the owner
  const daily = await rollups.aggregate(PlayRollupAggregations.servesByDay(scope));
  // => [{ day: "2026-10-02", served: 4 }, …]: oldest first, empty days absent
  const metrics = await rollups.aggregate(PlayRollupAggregations.metricsByCode(scope));
  // => [{ code, starsSum, resultsCount, difficultySum, gradedCount }, …]
  return { daily, metrics };
}

PlayRollupRunsReadRepository and PlayRollupRunsRepository: the trace of the rollup executions. A day with no succeeded execution was never computed, which is not a day computed and found empty. A succeeded execution is traced by replaceDay, in the transaction that replaces its day; writeOne traces a failed one, outside the transaction it rolled back.

ExportDefinition
PlayRollupRunWhere{ succeeded?: boolean; days?: UtcDayRange }.
PlayRollupRunCriteriaStatic succeededWithin(range), where; instance and.
PlayRollupRunAggregationsdays(criteria) → string[], the distinct days of the executions it scopes.
MethodSignatureDescription
aggregate<T>(aggregation: Aggregation<T>) => Promise<T>Read side.
writeOne(run: FailedPlayRollupRun) => Promise<void>Write side: a failed execution.

UsagePerseveranceReadRepository: findMany over the precomputed perseverance snapshot. UsagePerseveranceQuery.empty().byOwner(id).inWindow(kind).withCodes(codes) with the UsagePerseveranceProjections.perDeal() projection returns UsageDealPerseverance[]; an empty code list matches nothing and costs no round trip. A deal absent from the snapshot has nothing to report.

PlayRollupsRepository: the nightly computation of a day, bespoke. Daily aggregates over serves and telemetry events, one row per (owner account, deal code, UTC day). A serve counts once it was displayed, never at resolve; every other counter counts plays, and replaying a deal counts again. Aggregates outlive their sources: no foreign keys, permanent retention.

Prop

Type

MethodSignatureDescription
countServes(window: UtcDayWindow) => Promise<ServeDailyCount[]>Displayed serves grouped by (owner, code), bucketed on first_event_at.
countPlayEvents(window: UtcDayWindow) => Promise<PlayEventDailyCount[]>Play-entered and play-finished events counted one by one, bucketed on the event's received_at. Events without a resolvable serve are excluded.
countResultStars(window: UtcDayWindow) => Promise<ResultStarsDailyCount[]>Star sums over every result reported in the window; a deal replayed twice weighs twice.
listDifficultySamples(window: UtcDayWindow) => Promise<DifficultySample[]>One sample per graded result, carrying the measures the difficulty rule needs; a measure never reported is null.
replaceDay(day: string, rows: PlayRollupRow[], run: PlayRollupRun) => Promise<void>Atomically replaces the day's rows and traces the execution.

PlayRollupKey is { ownerAccountId: string | null; code: string }; ServeDailyCount, PlayEventDailyCount, ResultStarsDailyCount and DifficultySample (the key plus a PerceivedDifficultyPlay) extend it. PlayRollupRunSource is "cron" | "retry" | "backfill"; PlayRollupRun is { source; durationMs } and FailedPlayRollupRun adds day and reason. UtcDayWindow is { start: Date; end: Date }, half-open.

PlayUsageReader: the reads of the usage views that span several tables, bespoke. Days the job covered come from the rollups; everything after the watermark is counted live from displayed serves and raw telemetry, on the same rule as the rollups. Reads that share a table, a key and a window are one method, not several.

UtcDayRange is { fromDay: string; toDayExclusive: string }, both YYYY-MM-DD. UsageDealCodes is string[] | undefined: the codes a read is restricted to, undefined reading every deal of the owner.

MethodSignatureDescription
readPeriodCounters(ownerAccountId: string, query: { range: UtcDayRange; liveSince: Date; period: UsagePeriod }) => Promise<PlayUsageTotals & { adServed: number }>The period's counters in one pass: rolled-up days and the live span summed per metric, plus the ad-served serves of the exact window.
readTopDealsByServes(ownerAccountId: string, query: UsageTopDealsQuery) => Promise<UsageTopDeals>Ranks the deals of the window over rolled-up days and the live span merged, keeping the head. Also returns the live serves per day over every deal of the scope, ranked or not.
readLiveMetricsByCode(ownerAccountId: string, since: Date, codes?: UsageDealCodes) => Promise<{ stars: UsageDealStars[]; difficultySamples: UsageDifficultySample[] }>Star sums and difficulty samples of the live span. Plays whose auction errors were never measured weigh on the stars but yield no sample.
readOwnerRanking(ownerAccountId: string, query: UsageRankingQuery) => Promise<UsageRanking | null>The subject's precomputed standing and the rows around it, read by key, never ranked here. Null until the daily job has placed the subject.

Prop

Type

Prop

Type

Prop

Type

Prop

Type

Prop

Type

Prop

Type

Prop

Type

UsagePerseveranceWindowsRepository: the nightly computation of the perseverance snapshot, for the job alone, bespoke; the rows are read through UsagePerseveranceReadRepository.

MethodSignatureDescription
countAllWindows(windows: Record<UsageAnalyticsPeriod, UsagePeriod>) => Promise<UsagePerseveranceWindowRow[]>One scan of the serves, one row per (owner, code) and preset window with the serves count and the distinct-session count. Serves without a client session or without an owner are excluded.
replaceAll(snapshot: UsagePerseveranceWindowsSnapshot) => Promise<void>Atomic wipe and insert of the whole table.

Prop

Type

OwnerPeriodActivityRepository: write side of the precomputed standings, one row per (owner, period), filled by the daily job, bespoke. The serves it ranks are read through PlayRollupsReadRepository, the active accounts through the accounts' aggregate.

MethodSignatureDescription
replacePeriod(snapshot: OwnerPeriodSnapshot) => Promise<void>Atomically replaces the period's rows.

Prop

Type

Prop

Type

Errors

ErrorCodeThrown by
NotFoundErrorPLAN_GRID_NOT_FOUNDUsageStatsService.getForSubject for an unknown grid key.
FeatureDisabledErrorFEATURE_DISABLEDUsageAnalyticsService.getForSubject when the subject's entitlements lack usage_analytics.
NotFoundErrorGROUP_SESSION_NOT_FOUND, DEAL_COLLECTION_NOT_FOUNDUsageAnalyticsService.getForSubject when the scope is unknown, foreign or empty.

utcDayWindow, lookbackDayRange and resolveRollupBoundary throw a plain Error on malformed day inputs. See Errors for the base classes.