import type { DatabaseSync } from './sqlite'; import type { ImmersionSessionRollupRow } from './types'; import { ACTIVE_SESSION_METRICS_CTE, currentDbTimestamp, getLocalDayOfWeek, getLocalEpochDay, getLocalHourOfDay, getLocalMonthKey, getShiftedLocalDayTimestamp, makePlaceholders, toDbTimestamp, } from './query-shared'; import { getDailyRollups, getMonthlyRollups } from './query-sessions'; type TrendRange = '7d' | '30d' | '90d' | 'all'; type TrendGroupBy = 'day' | 'month'; interface TrendChartPoint { label: string; value: number; } interface TrendPerAnimePoint { epochDay: number; animeTitle: string; value: 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[]; }; animePerDay: { episodes: TrendPerAnimePoint[]; watchTime: TrendPerAnimePoint[]; cards: TrendPerAnimePoint[]; words: TrendPerAnimePoint[]; lookups: TrendPerAnimePoint[]; lookupsPerHundred: TrendPerAnimePoint[]; }; animeCumulative: { watchTime: TrendPerAnimePoint[]; episodes: TrendPerAnimePoint[]; cards: TrendPerAnimePoint[]; words: TrendPerAnimePoint[]; }; patterns: { watchTimeByDayOfWeek: TrendChartPoint[]; watchTimeByHour: TrendChartPoint[]; }; } const TREND_DAY_LIMITS: Record, number> = { '7d': 7, '30d': 30, '90d': 90, }; 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 resolveTrendAnimeTitle(value: { animeTitle: string | null; canonicalTitle: string | null; }): string { return value.animeTitle ?? value.canonicalTitle ?? 'Unknown'; } 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[]) { 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); } return Array.from(byKey.entries()) .sort(([left], [right]) => left - right) .map(([key, value]) => ({ label: makeTrendLabel(key), activeMin: Math.round(value.activeMin), cards: value.cards, words: value.words, sessions: value.sessions, })); } 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 dayLabel(epochDay: number): string { const { month, day } = dayPartsFromEpochDay(epochDay); return `${MONTH_NAMES[month - 1]} ${day}`; } function buildSessionSeriesByDay( sessions: TrendSessionMetricRow[], getValue: (session: TrendSessionMetricRow) => number, ): TrendChartPoint[] { const byDay = new Map(); for (const session of sessions) { byDay.set(session.epochDay, (byDay.get(session.epochDay) ?? 0) + getValue(session)); } return Array.from(byDay.entries()) .sort(([left], [right]) => left - right) .map(([epochDay, value]) => ({ label: dayLabel(epochDay), value })); } function buildSessionSeriesByMonth( sessions: TrendSessionMetricRow[], getValue: (session: TrendSessionMetricRow) => number, ): TrendChartPoint[] { const byMonth = new Map(); for (const session of sessions) { byMonth.set(session.monthKey, (byMonth.get(session.monthKey) ?? 0) + getValue(session)); } return Array.from(byMonth.entries()) .sort(([left], [right]) => left - right) .map(([monthKey, value]) => ({ label: makeTrendLabel(monthKey), value })); } function buildLookupsPerHundredWords( sessions: TrendSessionMetricRow[], groupBy: TrendGroupBy, ): 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), ); } return Array.from(lookupsByBucket.entries()) .sort(([left], [right]) => left - right) .map(([bucketKey, lookups]) => { const words = wordsByBucket.get(bucketKey) ?? 0; return { label: groupBy === 'month' ? makeTrendLabel(bucketKey) : dayLabel(bucketKey), value: words > 0 ? +((lookups / words) * 100).toFixed(1) : 0, }; }); } function buildPerAnimeFromSessions( sessions: TrendSessionMetricRow[], getValue: (session: TrendSessionMetricRow) => number, ): TrendPerAnimePoint[] { const byAnime = new Map>(); for (const session of sessions) { const animeTitle = resolveTrendAnimeTitle(session); const epochDay = session.epochDay; const dayMap = byAnime.get(animeTitle) ?? new Map(); dayMap.set(epochDay, (dayMap.get(epochDay) ?? 0) + getValue(session)); 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 buildLookupsPerHundredPerAnime(sessions: TrendSessionMetricRow[]): TrendPerAnimePoint[] { const lookups = new Map>(); const words = new Map>(); for (const session of sessions) { const animeTitle = resolveTrendAnimeTitle(session); const epochDay = session.epochDay; const lookupMap = lookups.get(animeTitle) ?? new Map(); lookupMap.set(epochDay, (lookupMap.get(epochDay) ?? 0) + session.yomitanLookupCount); lookups.set(animeTitle, lookupMap); const wordMap = words.get(animeTitle) ?? new Map(); wordMap.set(epochDay, (wordMap.get(epochDay) ?? 0) + getTrendSessionWordCount(session)); words.set(animeTitle, wordMap); } const result: TrendPerAnimePoint[] = []; for (const [animeTitle, dayMap] of lookups) { const wordMap = words.get(animeTitle) ?? new Map(); for (const [epochDay, lookupCount] of dayMap) { const wordCount = wordMap.get(epochDay) ?? 0; result.push({ epochDay, animeTitle, value: wordCount > 0 ? +((lookupCount / wordCount) * 100).toFixed(1) : 0, }); } } return result; } 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 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, 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 buildEpisodesPerDayFromDailyRollups( rollups: ImmersionSessionRollupRow[], ): TrendChartPoint[] { const byDay = new Map>(); for (const rollup of rollups) { if (rollup.videoId === null) { continue; } const videoIds = byDay.get(rollup.rollupDayOrMonth) ?? new Set(); videoIds.add(rollup.videoId); byDay.set(rollup.rollupDayOrMonth, videoIds); } return Array.from(byDay.entries()) .sort(([left], [right]) => left - right) .map(([epochDay, videoIds]) => ({ label: dayLabel(epochDay), value: videoIds.size, })); } function buildEpisodesPerMonthFromRollups(rollups: ImmersionSessionRollupRow[]): TrendChartPoint[] { const byMonth = new Map>(); for (const rollup of rollups) { if (rollup.videoId === null) { continue; } const videoIds = byMonth.get(rollup.rollupDayOrMonth) ?? new Set(); videoIds.add(rollup.videoId); byMonth.set(rollup.rollupDayOrMonth, videoIds); } return Array.from(byMonth.entries()) .sort(([left], [right]) => left - right) .map(([monthKey, videoIds]) => ({ label: makeTrendLabel(monthKey), value: videoIds.size, })); } function getTrendSessionMetrics( db: DatabaseSync, cutoffMs: string | null, ): TrendSessionMetricRow[] { 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, COALESCE(asm.tokensSeen, s.tokens_seen, 0) 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 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): 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; }>; return rows.map((row) => ({ label: dayLabel(row.epochDay), value: row.wordCount, })); } function buildNewWordsPerMonth(db: DatabaseSync, cutoffMs: string | 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; }>; return rows.map((row) => ({ label: makeTrendLabel(row.monthKey), value: row.wordCount, })); } export function getTrendsDashboard( db: DatabaseSync, range: TrendRange = '30d', groupBy: TrendGroupBy = 'day', ): 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); const chartRollups = useMonthlyBuckets ? monthlyRollups : dailyRollups; const sessions = getTrendSessionMetrics(db, cutoffMs); const titlesByVideoId = getVideoAnimeTitleMap( db, dailyRollups.map((rollup) => rollup.videoId), ); const aggregatedRows = buildAggregatedTrendRows(chartRollups); 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, ), lookups: buildPerAnimeFromSessions(sessions, (session) => session.yomitanLookupCount), lookupsPerHundred: buildLookupsPerHundredPerAnime(sessions), }; return { activity, progress: { watchTime: accumulatePoints(activity.watchTime), sessions: accumulatePoints(activity.sessions), words: accumulatePoints(activity.words), newWords: accumulatePoints( useMonthlyBuckets ? buildNewWordsPerMonth(db, cutoffMs) : buildNewWordsPerDay(db, cutoffMs), ), cards: accumulatePoints(activity.cards), episodes: accumulatePoints( useMonthlyBuckets ? buildEpisodesPerMonthFromRollups(monthlyRollups) : buildEpisodesPerDayFromDailyRollups(dailyRollups), ), lookups: accumulatePoints( useMonthlyBuckets ? buildSessionSeriesByMonth(sessions, (session) => session.yomitanLookupCount) : buildSessionSeriesByDay(sessions, (session) => session.yomitanLookupCount), ), }, ratios: { lookupsPerHundred: buildLookupsPerHundredWords(sessions, groupBy), }, animePerDay, animeCumulative: { watchTime: buildCumulativePerAnime(animePerDay.watchTime), episodes: buildCumulativePerAnime(animePerDay.episodes), cards: buildCumulativePerAnime(animePerDay.cards), words: buildCumulativePerAnime(animePerDay.words), }, patterns: { watchTimeByDayOfWeek: buildWatchTimeByDayOfWeek(sessions), watchTimeByHour: buildWatchTimeByHour(sessions), }, }; }