import type { DatabaseSync } from './sqlite'; import type { ImmersionSessionRollupRow } from './types'; import { ACTIVE_SESSION_METRICS_CTE, currentDbTimestamp, getLocalDayOfWeek, getLocalEpochDay, getLocalHourOfDay, getLocalMonthKey, getShiftedLocalDayTimestamp, makePlaceholders, sessionDisplayWordsExpr, toDbTimestamp, } from './query-shared'; import { getDailyRollups, getMonthlyRollups } from './query-sessions'; type TrendRange = '7d' | '30d' | '90d' | '365d' | 'all'; type TrendGroupBy = 'day' | 'month'; interface TrendChartPoint { label: string; value: number; } interface TrendPerAnimePoint { epochDay: number; animeTitle: string; value: number; } export interface LibrarySummaryRow { title: string; watchTimeMin: number; videos: number; sessions: number; cards: number; words: number; lookups: number; lookupsPerHundred: number | null; firstWatched: number; lastWatched: number; } interface TrendSessionMetricRow { startedAtMs: number; epochDay: number; monthKey: number; dayOfWeek: number; hourOfDay: number; videoId: number | null; canonicalTitle: string | null; animeTitle: string | null; activeWatchedMs: number; tokensSeen: number; cardsMined: number; yomitanLookupCount: number; } export interface TrendsDashboardQueryResult { activity: { watchTime: TrendChartPoint[]; cards: TrendChartPoint[]; words: TrendChartPoint[]; sessions: TrendChartPoint[]; }; progress: { watchTime: TrendChartPoint[]; sessions: TrendChartPoint[]; words: TrendChartPoint[]; newWords: TrendChartPoint[]; cards: TrendChartPoint[]; episodes: TrendChartPoint[]; lookups: TrendChartPoint[]; }; ratios: { lookupsPerHundred: TrendChartPoint[]; cardsPerHour: TrendChartPoint[]; readingSpeed: TrendChartPoint[]; }; animeCumulative: { watchTime: TrendPerAnimePoint[]; episodes: TrendPerAnimePoint[]; cards: TrendPerAnimePoint[]; words: TrendPerAnimePoint[]; }; patterns: { watchTimeByDayOfWeek: TrendChartPoint[]; watchTimeByHour: TrendChartPoint[]; }; librarySummary: LibrarySummaryRow[]; } const TREND_DAY_LIMITS: Record, number> = { '7d': 7, '30d': 30, '90d': 90, '365d': 365, }; const MONTH_NAMES = [ 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec', ]; const DAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; function getTrendDayLimit(range: TrendRange): number { return range === 'all' ? 365 : TREND_DAY_LIMITS[range]; } function getTrendMonthlyLimit(db: DatabaseSync, range: TrendRange): number { if (range === 'all') { return 120; } const currentTimestamp = currentDbTimestamp(); const todayStartMs = getShiftedLocalDayTimestamp(db, currentTimestamp, 0); const cutoffMs = getShiftedLocalDayTimestamp( db, currentTimestamp, -(TREND_DAY_LIMITS[range] - 1), ); const currentMonthKey = getLocalMonthKey(db, todayStartMs); const cutoffMonthKey = getLocalMonthKey(db, cutoffMs); const currentYear = Math.floor(currentMonthKey / 100); const currentMonth = currentMonthKey % 100; const cutoffYear = Math.floor(cutoffMonthKey / 100); const cutoffMonth = cutoffMonthKey % 100; return Math.max(1, (currentYear - cutoffYear) * 12 + currentMonth - cutoffMonth + 1); } function getTrendCutoffMs(db: DatabaseSync, range: TrendRange): string | null { if (range === 'all') { return null; } return getShiftedLocalDayTimestamp(db, currentDbTimestamp(), -(getTrendDayLimit(range) - 1)); } function dayPartsFromEpochDay(epochDay: number): { year: number; month: number; day: number } { const z = epochDay + 719468; const era = Math.floor(z / 146097); const doe = z - era * 146097; const yoe = Math.floor( (doe - Math.floor(doe / 1460) + Math.floor(doe / 36524) - Math.floor(doe / 146096)) / 365, ); let year = yoe + era * 400; const doy = doe - (365 * yoe + Math.floor(yoe / 4) - Math.floor(yoe / 100)); const mp = Math.floor((5 * doy + 2) / 153); const day = doy - Math.floor((153 * mp + 2) / 5) + 1; const month = mp < 10 ? mp + 3 : mp - 9; if (month <= 2) { year += 1; } return { year, month, day }; } function makeTrendLabel(value: number): string { if (value > 100_000) { const year = Math.floor(value / 100); const month = value % 100; return `${MONTH_NAMES[month - 1]} ${String(year).slice(-2)}`; } const { month, day } = dayPartsFromEpochDay(value); return `${MONTH_NAMES[month - 1]} ${day}`; } function getTrendSessionWordCount(session: Pick): number { return session.tokensSeen; } function looksLikeJellyfinStreamTitle(title: string): boolean { const lowered = title.toLowerCase(); const hasApiKey = /api[\s_-]*key(?:\s|=|$)/i.test(title); return ( hasApiKey && (lowered.includes('stream?') || lowered.includes('/stream?') || lowered.includes('/videos/') || lowered.includes('mediasourceid')) ); } function sanitizeTrendTitle(title: string): string { const normalized = title.trim(); if (!normalized) { return 'Unknown'; } return looksLikeJellyfinStreamTitle(normalized) ? 'Jellyfin Video' : normalized; } function resolveTrendAnimeTitle(value: { animeTitle: string | null; canonicalTitle: string | null; }): string { return sanitizeTrendTitle(value.animeTitle ?? value.canonicalTitle ?? 'Unknown'); } // Ordered list of bucket keys (epoch days or YYYYMM months) covering the // selected range, so charts render one point per calendar bucket instead of // silently collapsing to only the buckets that have activity. Returns null for // the unbounded "all" range, where builders fall back to the buckets in data. function buildBucketAxis( db: DatabaseSync, groupBy: TrendGroupBy, cutoffMs: string | null, referenceMs: string, ): number[] | null { if (cutoffMs === null) { return null; } if (groupBy === 'month') { const startKey = getLocalMonthKey(db, cutoffMs); const endKey = getLocalMonthKey(db, referenceMs); const keys: number[] = []; let year = Math.floor(startKey / 100); let month = startKey % 100; const endYear = Math.floor(endKey / 100); const endMonth = endKey % 100; while (year < endYear || (year === endYear && month <= endMonth)) { keys.push(year * 100 + month); month += 1; if (month > 12) { month = 1; year += 1; } } return keys; } const startDay = getLocalEpochDay(db, cutoffMs); const endDay = getLocalEpochDay(db, referenceMs); const keys: number[] = []; for (let day = startDay; day <= endDay; day += 1) { keys.push(day); } return keys; } // Project a bucket→value map onto the axis, filling absent buckets with zero. // Without an axis (the "all" range) it falls back to the populated buckets in // ascending order, preserving the previous behaviour. function fillAxisPoints( axis: number[] | null, valueByBucket: Map, ): TrendChartPoint[] { const keys = axis ?? [...valueByBucket.keys()].sort((left, right) => left - right); return keys.map((key) => ({ label: makeTrendLabel(key), value: valueByBucket.get(key) ?? 0 })); } function accumulatePoints(points: TrendChartPoint[]): TrendChartPoint[] { let sum = 0; return points.map((point) => { sum += point.value; return { label: point.label, value: sum, }; }); } function buildAggregatedTrendRows(rollups: ImmersionSessionRollupRow[], axis: number[] | null) { const byKey = new Map< number, { activeMin: number; cards: number; words: number; sessions: number } >(); for (const rollup of rollups) { const existing = byKey.get(rollup.rollupDayOrMonth) ?? { activeMin: 0, cards: 0, words: 0, sessions: 0, }; existing.activeMin += rollup.totalActiveMin; existing.cards += rollup.totalCards; existing.words += rollup.totalTokensSeen; existing.sessions += rollup.totalSessions; byKey.set(rollup.rollupDayOrMonth, existing); } const keys = axis ?? Array.from(byKey.keys()).sort((left, right) => left - right); return keys.map((key) => { const value = byKey.get(key) ?? { activeMin: 0, cards: 0, words: 0, sessions: 0 }; return { label: makeTrendLabel(key), activeMin: Math.round(value.activeMin), cards: value.cards, words: value.words, sessions: value.sessions, }; }); } function buildEfficiencyRates(rows: ReturnType): { cardsPerHour: TrendChartPoint[]; readingSpeed: TrendChartPoint[]; } { const cardsPerHour: TrendChartPoint[] = []; const readingSpeed: TrendChartPoint[] = []; for (const row of rows) { const hours = row.activeMin / 60; cardsPerHour.push({ label: row.label, value: hours > 0 ? +(row.cards / hours).toFixed(1) : 0, }); readingSpeed.push({ label: row.label, value: row.activeMin > 0 ? +(row.words / row.activeMin).toFixed(1) : 0, }); } return { cardsPerHour, readingSpeed }; } function buildWatchTimeByDayOfWeek(sessions: TrendSessionMetricRow[]): TrendChartPoint[] { const totals = new Array(7).fill(0); for (const session of sessions) { totals[session.dayOfWeek] += session.activeWatchedMs; } return DAY_NAMES.map((name, index) => ({ label: name, value: Math.round(totals[index] / 60_000), })); } function buildWatchTimeByHour(sessions: TrendSessionMetricRow[]): TrendChartPoint[] { const totals = new Array(24).fill(0); for (const session of sessions) { totals[session.hourOfDay] += session.activeWatchedMs; } return totals.map((ms, index) => ({ label: `${String(index).padStart(2, '0')}:00`, value: Math.round(ms / 60_000), })); } function buildSessionSeries( sessions: TrendSessionMetricRow[], groupBy: TrendGroupBy, getValue: (session: TrendSessionMetricRow) => number, axis: number[] | null, ): TrendChartPoint[] { const byBucket = new Map(); for (const session of sessions) { const bucketKey = groupBy === 'month' ? session.monthKey : session.epochDay; byBucket.set(bucketKey, (byBucket.get(bucketKey) ?? 0) + getValue(session)); } return fillAxisPoints(axis, byBucket); } function buildLookupsPerHundredWords( sessions: TrendSessionMetricRow[], groupBy: TrendGroupBy, axis: number[] | null, ): TrendChartPoint[] { const lookupsByBucket = new Map(); const wordsByBucket = new Map(); for (const session of sessions) { const bucketKey = groupBy === 'month' ? session.monthKey : session.epochDay; lookupsByBucket.set( bucketKey, (lookupsByBucket.get(bucketKey) ?? 0) + session.yomitanLookupCount, ); wordsByBucket.set( bucketKey, (wordsByBucket.get(bucketKey) ?? 0) + getTrendSessionWordCount(session), ); } const ratioByBucket = new Map(); for (const [bucketKey, lookups] of lookupsByBucket) { const words = wordsByBucket.get(bucketKey) ?? 0; ratioByBucket.set(bucketKey, words > 0 ? +((lookups / words) * 100).toFixed(1) : 0); } return fillAxisPoints(axis, ratioByBucket); } function buildCumulativePerAnime(points: TrendPerAnimePoint[]): TrendPerAnimePoint[] { const byAnime = new Map>(); const allDays = new Set(); for (const point of points) { const dayMap = byAnime.get(point.animeTitle) ?? new Map(); dayMap.set(point.epochDay, (dayMap.get(point.epochDay) ?? 0) + point.value); byAnime.set(point.animeTitle, dayMap); allDays.add(point.epochDay); } const sortedDays = [...allDays].sort((left, right) => left - right); if (sortedDays.length === 0) { return []; } const minDay = sortedDays[0]!; const maxDay = sortedDays[sortedDays.length - 1]!; const result: TrendPerAnimePoint[] = []; for (const [animeTitle, dayMap] of byAnime) { const firstDay = Math.min(...dayMap.keys()); let cumulative = 0; for (let epochDay = minDay; epochDay <= maxDay; epochDay += 1) { if (epochDay < firstDay) { continue; } cumulative += dayMap.get(epochDay) ?? 0; result.push({ epochDay, animeTitle, value: cumulative }); } } return result; } function buildLibrarySummary( rollups: ImmersionSessionRollupRow[], sessions: TrendSessionMetricRow[], titlesByVideoId: Map, ): LibrarySummaryRow[] { type Accum = { watchTimeMin: number; videos: Set; cards: number; words: number; firstWatched: number; lastWatched: number; sessions: number; lookups: number; }; const byTitle = new Map(); const ensure = (title: string): Accum => { const existing = byTitle.get(title); if (existing) return existing; const created: Accum = { watchTimeMin: 0, videos: new Set(), cards: 0, words: 0, firstWatched: Number.POSITIVE_INFINITY, lastWatched: Number.NEGATIVE_INFINITY, sessions: 0, lookups: 0, }; byTitle.set(title, created); return created; }; for (const rollup of rollups) { if (rollup.videoId === null) continue; const title = resolveVideoAnimeTitle(rollup.videoId, titlesByVideoId); const acc = ensure(title); acc.watchTimeMin += rollup.totalActiveMin; acc.cards += rollup.totalCards; acc.words += rollup.totalTokensSeen; acc.videos.add(rollup.videoId); if (rollup.rollupDayOrMonth < acc.firstWatched) { acc.firstWatched = rollup.rollupDayOrMonth; } if (rollup.rollupDayOrMonth > acc.lastWatched) { acc.lastWatched = rollup.rollupDayOrMonth; } } for (const session of sessions) { const title = resolveTrendAnimeTitle(session); if (!byTitle.has(title)) continue; const acc = byTitle.get(title)!; acc.sessions += 1; acc.lookups += session.yomitanLookupCount; } const rows: LibrarySummaryRow[] = []; for (const [title, acc] of byTitle) { if (!Number.isFinite(acc.firstWatched) || !Number.isFinite(acc.lastWatched)) { continue; } rows.push({ title, watchTimeMin: Math.round(acc.watchTimeMin), videos: acc.videos.size, sessions: acc.sessions, cards: acc.cards, words: acc.words, lookups: acc.lookups, lookupsPerHundred: acc.words > 0 ? +((acc.lookups / acc.words) * 100).toFixed(1) : null, firstWatched: acc.firstWatched, lastWatched: acc.lastWatched, }); } rows.sort((a, b) => b.watchTimeMin - a.watchTimeMin || a.title.localeCompare(b.title)); return rows; } function getVideoAnimeTitleMap( db: DatabaseSync, videoIds: Array, ): Map { const uniqueIds = [ ...new Set(videoIds.filter((value): value is number => typeof value === 'number')), ]; if (uniqueIds.length === 0) { return new Map(); } const rows = db .prepare( ` SELECT v.video_id AS videoId, COALESCE(a.canonical_title, v.canonical_title, 'Unknown') AS animeTitle FROM imm_videos v LEFT JOIN imm_anime a ON a.anime_id = v.anime_id WHERE v.video_id IN (${makePlaceholders(uniqueIds)}) `, ) .all(...uniqueIds) as Array<{ videoId: number; animeTitle: string }>; return new Map(rows.map((row) => [row.videoId, sanitizeTrendTitle(row.animeTitle)])); } function resolveVideoAnimeTitle( videoId: number | null, titlesByVideoId: Map, ): string { if (videoId === null) { return 'Unknown'; } return titlesByVideoId.get(videoId) ?? 'Unknown'; } function buildPerAnimeFromDailyRollups( rollups: ImmersionSessionRollupRow[], titlesByVideoId: Map, getValue: (rollup: ImmersionSessionRollupRow) => number, ): TrendPerAnimePoint[] { const byAnime = new Map>(); for (const rollup of rollups) { const animeTitle = resolveVideoAnimeTitle(rollup.videoId, titlesByVideoId); const dayMap = byAnime.get(animeTitle) ?? new Map(); dayMap.set( rollup.rollupDayOrMonth, (dayMap.get(rollup.rollupDayOrMonth) ?? 0) + getValue(rollup), ); byAnime.set(animeTitle, dayMap); } const result: TrendPerAnimePoint[] = []; for (const [animeTitle, dayMap] of byAnime) { for (const [epochDay, value] of dayMap) { result.push({ epochDay, animeTitle, value }); } } return result; } function buildEpisodesPerAnimeFromDailyRollups( rollups: ImmersionSessionRollupRow[], titlesByVideoId: Map, ): TrendPerAnimePoint[] { const byAnime = new Map>>(); for (const rollup of rollups) { if (rollup.videoId === null) { continue; } const animeTitle = resolveVideoAnimeTitle(rollup.videoId, titlesByVideoId); const dayMap = byAnime.get(animeTitle) ?? new Map(); const videoIds = dayMap.get(rollup.rollupDayOrMonth) ?? new Set(); videoIds.add(rollup.videoId); dayMap.set(rollup.rollupDayOrMonth, videoIds); byAnime.set(animeTitle, dayMap); } const result: TrendPerAnimePoint[] = []; for (const [animeTitle, dayMap] of byAnime) { for (const [epochDay, videoIds] of dayMap) { result.push({ epochDay, animeTitle, value: videoIds.size }); } } return result; } function buildEpisodesSeriesFromRollups( rollups: ImmersionSessionRollupRow[], axis: number[] | null, ): TrendChartPoint[] { const byBucket = new Map>(); for (const rollup of rollups) { if (rollup.videoId === null) { continue; } const videoIds = byBucket.get(rollup.rollupDayOrMonth) ?? new Set(); videoIds.add(rollup.videoId); byBucket.set(rollup.rollupDayOrMonth, videoIds); } const counts = new Map(); for (const [bucketKey, videoIds] of byBucket) { counts.set(bucketKey, videoIds.size); } return fillAxisPoints(axis, counts); } function getTrendSessionMetrics( db: DatabaseSync, cutoffMs: string | null, ): TrendSessionMetricRow[] { const wordsExpr = sessionDisplayWordsExpr('s', 'swc', 'COALESCE(asm.tokensSeen, s.tokens_seen)'); const whereClause = cutoffMs === null ? '' : 'WHERE s.started_at_ms >= ?'; const cutoffValue = cutoffMs === null ? null : toDbTimestamp(cutoffMs); const prepared = db.prepare(` ${ACTIVE_SESSION_METRICS_CTE} SELECT s.started_at_ms AS startedAtMs, s.video_id AS videoId, v.canonical_title AS canonicalTitle, a.canonical_title AS animeTitle, COALESCE(asm.activeWatchedMs, s.active_watched_ms, 0) AS activeWatchedMs, ${wordsExpr} AS tokensSeen, COALESCE(asm.cardsMined, s.cards_mined, 0) AS cardsMined, COALESCE(asm.yomitanLookupCount, s.yomitan_lookup_count, 0) AS yomitanLookupCount FROM imm_sessions s LEFT JOIN active_session_metrics asm ON asm.sessionId = s.session_id LEFT JOIN session_word_counts swc ON swc.sessionId = s.session_id LEFT JOIN imm_videos v ON v.video_id = s.video_id LEFT JOIN imm_anime a ON a.anime_id = v.anime_id ${whereClause} ORDER BY s.started_at_ms ASC `); const rows = (cutoffValue === null ? prepared.all() : prepared.all(cutoffValue)) as Array< TrendSessionMetricRow & { startedAtMs: number | string } >; return rows.map((row) => ({ ...row, startedAtMs: 0, epochDay: getLocalEpochDay(db, row.startedAtMs), monthKey: getLocalMonthKey(db, row.startedAtMs), dayOfWeek: getLocalDayOfWeek(db, row.startedAtMs), hourOfDay: getLocalHourOfDay(db, row.startedAtMs), })); } function buildNewWordsPerDay( db: DatabaseSync, cutoffMs: string | null, axis: number[] | null, ): TrendChartPoint[] { const whereClause = cutoffMs === null ? '' : 'AND first_seen >= ?'; const prepared = db.prepare(` SELECT CAST( julianday(CAST(first_seen AS REAL), 'unixepoch', 'localtime') - 2440587.5 AS INTEGER ) AS epochDay, COUNT(*) AS wordCount FROM imm_words WHERE first_seen IS NOT NULL ${whereClause} GROUP BY epochDay ORDER BY epochDay ASC `); const rows = ( cutoffMs === null ? prepared.all() : prepared.all((BigInt(cutoffMs) / 1000n).toString()) ) as Array<{ epochDay: number; wordCount: number; }>; const byBucket = new Map(rows.map((row) => [row.epochDay, row.wordCount])); return fillAxisPoints(axis, byBucket); } function buildNewWordsPerMonth( db: DatabaseSync, cutoffMs: string | null, axis: number[] | null, ): TrendChartPoint[] { const whereClause = cutoffMs === null ? '' : 'AND first_seen >= ?'; const prepared = db.prepare(` SELECT CAST( strftime('%Y%m', CAST(first_seen AS REAL), 'unixepoch', 'localtime') AS INTEGER ) AS monthKey, COUNT(*) AS wordCount FROM imm_words WHERE first_seen IS NOT NULL ${whereClause} GROUP BY monthKey ORDER BY monthKey ASC `); const rows = ( cutoffMs === null ? prepared.all() : prepared.all((BigInt(cutoffMs) / 1000n).toString()) ) as Array<{ monthKey: number; wordCount: number; }>; const byBucket = new Map(rows.map((row) => [row.monthKey, row.wordCount])); return fillAxisPoints(axis, byBucket); } export function getTrendsDashboard( db: DatabaseSync, range: TrendRange = '30d', groupBy: TrendGroupBy = 'day', fillEmptyBuckets = true, ): TrendsDashboardQueryResult { const dayLimit = getTrendDayLimit(range); const monthlyLimit = getTrendMonthlyLimit(db, range); const cutoffMs = getTrendCutoffMs(db, range); const useMonthlyBuckets = groupBy === 'month'; const dailyRollups = getDailyRollups(db, dayLimit); const monthlyRollups = getMonthlyRollups(db, monthlyLimit); // A null axis makes the builders fall back to only the buckets present in the // data; the contiguous axis zero-fills every calendar bucket in the window. const bucketAxis = fillEmptyBuckets ? buildBucketAxis(db, groupBy, cutoffMs, currentDbTimestamp()) : null; const chartRollups = useMonthlyBuckets ? monthlyRollups : dailyRollups; const sessions = getTrendSessionMetrics(db, cutoffMs); const titlesByVideoId = getVideoAnimeTitleMap( db, dailyRollups.map((rollup) => rollup.videoId), ); const aggregatedRows = buildAggregatedTrendRows(chartRollups, bucketAxis); const efficiency = buildEfficiencyRates(aggregatedRows); const activity = { watchTime: aggregatedRows.map((row) => ({ label: row.label, value: row.activeMin })), cards: aggregatedRows.map((row) => ({ label: row.label, value: row.cards })), words: aggregatedRows.map((row) => ({ label: row.label, value: row.words })), sessions: aggregatedRows.map((row) => ({ label: row.label, value: row.sessions })), }; const animePerDay = { episodes: buildEpisodesPerAnimeFromDailyRollups(dailyRollups, titlesByVideoId), watchTime: buildPerAnimeFromDailyRollups( dailyRollups, titlesByVideoId, (rollup) => rollup.totalActiveMin, ), cards: buildPerAnimeFromDailyRollups( dailyRollups, titlesByVideoId, (rollup) => rollup.totalCards, ), words: buildPerAnimeFromDailyRollups( dailyRollups, titlesByVideoId, (rollup) => rollup.totalTokensSeen, ), }; return { activity, progress: { watchTime: accumulatePoints(activity.watchTime), sessions: accumulatePoints(activity.sessions), words: accumulatePoints(activity.words), newWords: accumulatePoints( useMonthlyBuckets ? buildNewWordsPerMonth(db, cutoffMs, bucketAxis) : buildNewWordsPerDay(db, cutoffMs, bucketAxis), ), cards: accumulatePoints(activity.cards), episodes: accumulatePoints( buildEpisodesSeriesFromRollups( useMonthlyBuckets ? monthlyRollups : dailyRollups, bucketAxis, ), ), lookups: accumulatePoints( buildSessionSeries(sessions, groupBy, (session) => session.yomitanLookupCount, bucketAxis), ), }, ratios: { lookupsPerHundred: buildLookupsPerHundredWords(sessions, groupBy, bucketAxis), cardsPerHour: efficiency.cardsPerHour, readingSpeed: efficiency.readingSpeed, }, animeCumulative: { watchTime: buildCumulativePerAnime(animePerDay.watchTime), episodes: buildCumulativePerAnime(animePerDay.episodes), cards: buildCumulativePerAnime(animePerDay.cards), words: buildCumulativePerAnime(animePerDay.words), }, patterns: { watchTimeByDayOfWeek: buildWatchTimeByDayOfWeek(sessions), watchTimeByHour: buildWatchTimeByHour(sessions), }, librarySummary: buildLibrarySummary(dailyRollups, sessions, titlesByVideoId), }; }