feat(stats): Trends dashboard overhaul — title visibility, ranking modes, calendar-accurate windows, tooltips (#140)

This commit is contained in:
2026-07-06 23:52:43 -07:00
committed by GitHub
parent 48a084914a
commit 8b9a70c5a6
16 changed files with 969 additions and 135 deletions
@@ -729,7 +729,7 @@ describe('stats server API routes', () => {
const res = await app.request('/api/stats/trends/dashboard?range=90d&groupBy=month');
assert.equal(res.status, 200);
const body = await res.json();
assert.deepEqual(seenArgs, ['90d', 'month']);
assert.deepEqual(seenArgs, ['90d', 'month', true]);
assert.deepEqual(body.activity.watchTime, TRENDS_DASHBOARD.activity.watchTime);
assert.deepEqual(body.librarySummary, TRENDS_DASHBOARD.librarySummary);
});
@@ -747,7 +747,7 @@ describe('stats server API routes', () => {
const res = await app.request('/api/stats/trends/dashboard?range=365d&groupBy=month');
assert.equal(res.status, 200);
assert.deepEqual(seenArgs, ['365d', 'month']);
assert.deepEqual(seenArgs, ['365d', 'month', true]);
});
it('GET /api/stats/trends/dashboard falls back to safe defaults for invalid params', async () => {
@@ -763,7 +763,25 @@ describe('stats server API routes', () => {
const res = await app.request('/api/stats/trends/dashboard?range=weird&groupBy=year');
assert.equal(res.status, 200);
assert.deepEqual(seenArgs, ['30d', 'day']);
assert.deepEqual(seenArgs, ['30d', 'day', true]);
});
it('GET /api/stats/trends/dashboard forwards fillEmpty=false to disable zero-fill', async () => {
let seenArgs: unknown[] = [];
const app = createStatsApp(
createMockTracker({
getTrendsDashboard: async (...args: unknown[]) => {
seenArgs = args;
return TRENDS_DASHBOARD;
},
}),
);
const res = await app.request(
'/api/stats/trends/dashboard?range=30d&groupBy=day&fillEmpty=false',
);
assert.equal(res.status, 200);
assert.deepEqual(seenArgs, ['30d', 'day', false]);
});
it('GET /api/stats/vocabulary/occurrences returns recent occurrence rows for a word', async () => {
@@ -571,8 +571,9 @@ export class ImmersionTrackerService {
async getTrendsDashboard(
range: '7d' | '30d' | '90d' | '365d' | 'all' = '30d',
groupBy: 'day' | 'month' = 'day',
fillEmptyBuckets = true,
): Promise<unknown> {
return getTrendsDashboard(this.db, range, groupBy);
return getTrendsDashboard(this.db, range, groupBy, fillEmptyBuckets);
}
async getVocabularyStats(limit = 100, excludePos?: string[]): Promise<VocabularyStatsRow[]> {
@@ -1003,6 +1003,121 @@ test('getTrendsDashboard keeps local-midnight session buckets separate', () => {
}
});
test('getTrendsDashboard 30d day range zero-fills empty calendar days', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
withMockNowMs('1772395200000', () => {
try {
ensureSchema(db);
const videoId = getOrCreateVideoRecord(db, 'local:/tmp/30d-zerofill.mkv', {
canonicalTitle: '30d Zero Fill',
sourcePath: '/tmp/30d-zerofill.mkv',
sourceUrl: null,
sourceType: SOURCE_TYPE_LOCAL,
});
const insertDailyRollup = db.prepare(
`
INSERT INTO imm_daily_rollups (
rollup_day, video_id, total_sessions, total_active_min, total_lines_seen,
total_tokens_seen, total_cards, CREATED_DATE, LAST_UPDATE_DATE
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
);
const createdAtMs = '1772395200000';
// Local "today" for the mocked clock is epoch day 20513. Seed only two
// active days inside the 30-day window, leaving the rest empty.
const todayEpochDay = 20513;
insertDailyRollup.run(todayEpochDay, videoId, 1, 30, 4, 100, 2, createdAtMs, createdAtMs);
insertDailyRollup.run(
todayEpochDay - 10,
videoId,
1,
45,
4,
120,
3,
createdAtMs,
createdAtMs,
);
const dashboard = getTrendsDashboard(db, '30d', 'day');
// Exactly 30 calendar days, not just the two active ones.
assert.equal(dashboard.activity.watchTime.length, 30);
// Most recent day carries its seeded value; a gap day reads zero.
assert.equal(dashboard.activity.watchTime.at(-1)?.value, 30);
assert.equal(dashboard.activity.watchTime.at(-2)?.value, 0);
// Only the two seeded days contribute to the totals.
const nonZeroDays = dashboard.activity.watchTime.filter((point) => point.value > 0);
assert.equal(nonZeroDays.length, 2);
// Cumulative watch time still tops out at the sum of both active days.
assert.equal(dashboard.progress.watchTime.at(-1)?.value, 75);
// Every day-bucketed series shares the same 30-day axis.
assert.equal(dashboard.progress.episodes.length, 30);
assert.deepEqual(
dashboard.progress.episodes.map((point) => point.label),
dashboard.activity.watchTime.map((point) => point.label),
);
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
});
test('getTrendsDashboard skips empty calendar days when zero-fill is disabled', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
withMockNowMs('1772395200000', () => {
try {
ensureSchema(db);
const videoId = getOrCreateVideoRecord(db, 'local:/tmp/no-zerofill.mkv', {
canonicalTitle: 'No Zero Fill',
sourcePath: '/tmp/no-zerofill.mkv',
sourceUrl: null,
sourceType: SOURCE_TYPE_LOCAL,
});
const insertDailyRollup = db.prepare(
`
INSERT INTO imm_daily_rollups (
rollup_day, video_id, total_sessions, total_active_min, total_lines_seen,
total_tokens_seen, total_cards, CREATED_DATE, LAST_UPDATE_DATE
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
);
const createdAtMs = '1772395200000';
const todayEpochDay = 20513;
insertDailyRollup.run(todayEpochDay, videoId, 1, 30, 4, 100, 2, createdAtMs, createdAtMs);
insertDailyRollup.run(
todayEpochDay - 10,
videoId,
1,
45,
4,
120,
3,
createdAtMs,
createdAtMs,
);
const filled = getTrendsDashboard(db, '30d', 'day', true);
assert.equal(filled.activity.watchTime.length, 30);
const compact = getTrendsDashboard(db, '30d', 'day', false);
// Only the two active days survive; no zero-filled gaps.
assert.equal(compact.activity.watchTime.length, 2);
assert.ok(compact.activity.watchTime.every((point) => point.value > 0));
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
});
test(
'getTrendsDashboard supports 365d range and caps day buckets at 365',
{ timeout: 20_000 },
@@ -1266,7 +1381,10 @@ test('getTrendsDashboard month grouping spans every touched calendar month and k
const dashboard = getTrendsDashboard(db, '30d', 'month');
assert.equal(dashboard.activity.watchTime.length, 2);
// The 30d window (mocked now Mar 1 → cutoff Jan 31) spans three calendar
// months, so January is zero-filled rather than dropped.
assert.equal(dashboard.activity.watchTime.length, 3);
assert.equal(dashboard.activity.watchTime[0]?.value, 0);
assert.deepEqual(
dashboard.progress.newWords.map((point) => point.label),
dashboard.activity.watchTime.map((point) => point.label),
@@ -205,6 +205,59 @@ function resolveTrendAnimeTitle(value: {
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<number, number>,
): 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) => {
@@ -216,7 +269,7 @@ function accumulatePoints(points: TrendChartPoint[]): TrendChartPoint[] {
});
}
function buildAggregatedTrendRows(rollups: ImmersionSessionRollupRow[]) {
function buildAggregatedTrendRows(rollups: ImmersionSessionRollupRow[], axis: number[] | null) {
const byKey = new Map<
number,
{ activeMin: number; cards: number; words: number; sessions: number }
@@ -236,15 +289,17 @@ function buildAggregatedTrendRows(rollups: ImmersionSessionRollupRow[]) {
byKey.set(rollup.rollupDayOrMonth, existing);
}
return Array.from(byKey.entries())
.sort(([left], [right]) => left - right)
.map(([key, value]) => ({
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<typeof buildAggregatedTrendRows>): {
@@ -289,40 +344,24 @@ function buildWatchTimeByHour(sessions: TrendSessionMetricRow[]): TrendChartPoin
}));
}
function dayLabel(epochDay: number): string {
const { month, day } = dayPartsFromEpochDay(epochDay);
return `${MONTH_NAMES[month - 1]} ${day}`;
}
function buildSessionSeriesByDay(
function buildSessionSeries(
sessions: TrendSessionMetricRow[],
groupBy: TrendGroupBy,
getValue: (session: TrendSessionMetricRow) => number,
axis: number[] | null,
): TrendChartPoint[] {
const byDay = new Map<number, number>();
const byBucket = new Map<number, number>();
for (const session of sessions) {
byDay.set(session.epochDay, (byDay.get(session.epochDay) ?? 0) + getValue(session));
const bucketKey = groupBy === 'month' ? session.monthKey : session.epochDay;
byBucket.set(bucketKey, (byBucket.get(bucketKey) ?? 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<number, number>();
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 }));
return fillAxisPoints(axis, byBucket);
}
function buildLookupsPerHundredWords(
sessions: TrendSessionMetricRow[],
groupBy: TrendGroupBy,
axis: number[] | null,
): TrendChartPoint[] {
const lookupsByBucket = new Map<number, number>();
const wordsByBucket = new Map<number, number>();
@@ -339,15 +378,12 @@ function buildLookupsPerHundredWords(
);
}
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,
};
});
const ratioByBucket = new Map<number, number>();
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[] {
@@ -557,46 +593,26 @@ function buildEpisodesPerAnimeFromDailyRollups(
return result;
}
function buildEpisodesPerDayFromDailyRollups(
function buildEpisodesSeriesFromRollups(
rollups: ImmersionSessionRollupRow[],
axis: number[] | null,
): TrendChartPoint[] {
const byDay = new Map<number, Set<number>>();
const byBucket = new Map<number, Set<number>>();
for (const rollup of rollups) {
if (rollup.videoId === null) {
continue;
}
const videoIds = byDay.get(rollup.rollupDayOrMonth) ?? new Set<number>();
const videoIds = byBucket.get(rollup.rollupDayOrMonth) ?? new Set<number>();
videoIds.add(rollup.videoId);
byDay.set(rollup.rollupDayOrMonth, videoIds);
byBucket.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<number, Set<number>>();
for (const rollup of rollups) {
if (rollup.videoId === null) {
continue;
}
const videoIds = byMonth.get(rollup.rollupDayOrMonth) ?? new Set<number>();
videoIds.add(rollup.videoId);
byMonth.set(rollup.rollupDayOrMonth, videoIds);
const counts = new Map<number, number>();
for (const [bucketKey, videoIds] of byBucket) {
counts.set(bucketKey, videoIds.size);
}
return Array.from(byMonth.entries())
.sort(([left], [right]) => left - right)
.map(([monthKey, videoIds]) => ({
label: makeTrendLabel(monthKey),
value: videoIds.size,
}));
return fillAxisPoints(axis, counts);
}
function getTrendSessionMetrics(
@@ -639,7 +655,11 @@ function getTrendSessionMetrics(
}));
}
function buildNewWordsPerDay(db: DatabaseSync, cutoffMs: string | null): TrendChartPoint[] {
function buildNewWordsPerDay(
db: DatabaseSync,
cutoffMs: string | null,
axis: number[] | null,
): TrendChartPoint[] {
const whereClause = cutoffMs === null ? '' : 'AND first_seen >= ?';
const prepared = db.prepare(`
SELECT
@@ -662,13 +682,15 @@ function buildNewWordsPerDay(db: DatabaseSync, cutoffMs: string | null): TrendCh
wordCount: number;
}>;
return rows.map((row) => ({
label: dayLabel(row.epochDay),
value: row.wordCount,
}));
const byBucket = new Map<number, number>(rows.map((row) => [row.epochDay, row.wordCount]));
return fillAxisPoints(axis, byBucket);
}
function buildNewWordsPerMonth(db: DatabaseSync, cutoffMs: string | null): TrendChartPoint[] {
function buildNewWordsPerMonth(
db: DatabaseSync,
cutoffMs: string | null,
axis: number[] | null,
): TrendChartPoint[] {
const whereClause = cutoffMs === null ? '' : 'AND first_seen >= ?';
const prepared = db.prepare(`
SELECT
@@ -691,16 +713,15 @@ function buildNewWordsPerMonth(db: DatabaseSync, cutoffMs: string | null): Trend
wordCount: number;
}>;
return rows.map((row) => ({
label: makeTrendLabel(row.monthKey),
value: row.wordCount,
}));
const byBucket = new Map<number, number>(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);
@@ -708,6 +729,11 @@ export function getTrendsDashboard(
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);
@@ -716,7 +742,7 @@ export function getTrendsDashboard(
dailyRollups.map((rollup) => rollup.videoId),
);
const aggregatedRows = buildAggregatedTrendRows(chartRollups);
const aggregatedRows = buildAggregatedTrendRows(chartRollups, bucketAxis);
const efficiency = buildEfficiencyRates(aggregatedRows);
const activity = {
watchTime: aggregatedRows.map((row) => ({ label: row.label, value: row.activeMin })),
@@ -751,22 +777,23 @@ export function getTrendsDashboard(
sessions: accumulatePoints(activity.sessions),
words: accumulatePoints(activity.words),
newWords: accumulatePoints(
useMonthlyBuckets ? buildNewWordsPerMonth(db, cutoffMs) : buildNewWordsPerDay(db, cutoffMs),
useMonthlyBuckets
? buildNewWordsPerMonth(db, cutoffMs, bucketAxis)
: buildNewWordsPerDay(db, cutoffMs, bucketAxis),
),
cards: accumulatePoints(activity.cards),
episodes: accumulatePoints(
useMonthlyBuckets
? buildEpisodesPerMonthFromRollups(monthlyRollups)
: buildEpisodesPerDayFromDailyRollups(dailyRollups),
buildEpisodesSeriesFromRollups(
useMonthlyBuckets ? monthlyRollups : dailyRollups,
bucketAxis,
),
),
lookups: accumulatePoints(
useMonthlyBuckets
? buildSessionSeriesByMonth(sessions, (session) => session.yomitanLookupCount)
: buildSessionSeriesByDay(sessions, (session) => session.yomitanLookupCount),
buildSessionSeries(sessions, groupBy, (session) => session.yomitanLookupCount, bucketAxis),
),
},
ratios: {
lookupsPerHundred: buildLookupsPerHundredWords(sessions, groupBy),
lookupsPerHundred: buildLookupsPerHundredWords(sessions, groupBy, bucketAxis),
cardsPerHour: efficiency.cardsPerHour,
readingSpeed: efficiency.readingSpeed,
},
+8 -1
View File
@@ -81,6 +81,12 @@ function parseTrendGroupBy(raw: string | undefined): 'day' | 'month' {
return raw === 'month' ? 'month' : 'day';
}
// Defaults to true (zero-fill empty calendar buckets); only an explicit
// "false" opts into the compact, data-only view.
function parseTrendFillEmpty(raw: string | undefined): boolean {
return raw !== 'false';
}
function parseEventTypesQuery(raw: string | undefined): number[] | undefined {
if (!raw) return undefined;
const parsed = raw
@@ -685,7 +691,8 @@ export function createStatsApp(
app.get('/api/stats/trends/dashboard', async (c) => {
const range = parseTrendRange(c.req.query('range'));
const groupBy = parseTrendGroupBy(c.req.query('groupBy'));
return c.json(await tracker.getTrendsDashboard(range, groupBy));
const fillEmpty = parseTrendFillEmpty(c.req.query('fillEmpty'));
return c.json(await tracker.getTrendsDashboard(range, groupBy, fillEmpty));
});
app.get('/api/stats/sessions', async (c) => {