diff --git a/changes/stats-trend-title-limits.md b/changes/stats-trend-title-limits.md new file mode 100644 index 00000000..a1b6ebfc --- /dev/null +++ b/changes/stats-trend-title-limits.md @@ -0,0 +1,4 @@ +type: fixed +area: stats + +- Show all trend chart titles by default, persist hidden-title choices, and add a per-chart top-title limit selector. diff --git a/src/core/services/__tests__/stats-server.test.ts b/src/core/services/__tests__/stats-server.test.ts index ac68e8d5..823fcca9 100644 --- a/src/core/services/__tests__/stats-server.test.ts +++ b/src/core/services/__tests__/stats-server.test.ts @@ -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 () => { diff --git a/src/core/services/immersion-tracker-service.ts b/src/core/services/immersion-tracker-service.ts index ee233edc..225c5cb2 100644 --- a/src/core/services/immersion-tracker-service.ts +++ b/src/core/services/immersion-tracker-service.ts @@ -571,8 +571,9 @@ export class ImmersionTrackerService { async getTrendsDashboard( range: '7d' | '30d' | '90d' | '365d' | 'all' = '30d', groupBy: 'day' | 'month' = 'day', + fillEmptyBuckets = true, ): Promise { - return getTrendsDashboard(this.db, range, groupBy); + return getTrendsDashboard(this.db, range, groupBy, fillEmptyBuckets); } async getVocabularyStats(limit = 100, excludePos?: string[]): Promise { diff --git a/src/core/services/immersion-tracker/__tests__/query.test.ts b/src/core/services/immersion-tracker/__tests__/query.test.ts index 2ace3cd6..db7190bf 100644 --- a/src/core/services/immersion-tracker/__tests__/query.test.ts +++ b/src/core/services/immersion-tracker/__tests__/query.test.ts @@ -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), diff --git a/src/core/services/immersion-tracker/query-trends.ts b/src/core/services/immersion-tracker/query-trends.ts index 072c7602..b0cf0ef5 100644 --- a/src/core/services/immersion-tracker/query-trends.ts +++ b/src/core/services/immersion-tracker/query-trends.ts @@ -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, +): 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): { @@ -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(); + const byBucket = new Map(); 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(); - 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(); const wordsByBucket = new Map(); @@ -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(); + 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>(); + const byBucket = new Map>(); for (const rollup of rollups) { if (rollup.videoId === null) { continue; } - const videoIds = byDay.get(rollup.rollupDayOrMonth) ?? new Set(); + const videoIds = byBucket.get(rollup.rollupDayOrMonth) ?? new Set(); 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>(); - - 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); + const counts = new Map(); + 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(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(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, }, diff --git a/src/core/services/stats-server.ts b/src/core/services/stats-server.ts index 144a176c..24c0c38a 100644 --- a/src/core/services/stats-server.ts +++ b/src/core/services/stats-server.ts @@ -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) => { diff --git a/stats/src/components/trends/DateRangeSelector.tsx b/stats/src/components/trends/DateRangeSelector.tsx index c46c3979..fa34fa99 100644 --- a/stats/src/components/trends/DateRangeSelector.tsx +++ b/stats/src/components/trends/DateRangeSelector.tsx @@ -3,8 +3,10 @@ import type { TimeRange, GroupBy } from '../../hooks/useTrends'; interface DateRangeSelectorProps { range: TimeRange; groupBy: GroupBy; + fillEmpty: boolean; onRangeChange: (r: TimeRange) => void; onGroupByChange: (g: GroupBy) => void; + onFillEmptyChange: (fillEmpty: boolean) => void; } function SegmentedControl({ @@ -46,11 +48,13 @@ function SegmentedControl({ export function DateRangeSelector({ range, groupBy, + fillEmpty, onRangeChange, onGroupByChange, + onFillEmptyChange, }: DateRangeSelectorProps) { return ( -
+
g.charAt(0).toUpperCase() + g.slice(1)} /> + onFillEmptyChange(v === 'show')} + formatLabel={(v) => (v === 'show' ? 'Show' : 'Hide')} + />
); } diff --git a/stats/src/components/trends/StackedTrendChart.test.ts b/stats/src/components/trends/StackedTrendChart.test.ts new file mode 100644 index 00000000..2c5bcb6f --- /dev/null +++ b/stats/src/components/trends/StackedTrendChart.test.ts @@ -0,0 +1,122 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + buildLineData, + sortTooltipEntries, + tooltipColumnCount, + type PerAnimeDataPoint, +} from './StackedTrendChart'; + +function makePoints(titleCount: number): PerAnimeDataPoint[] { + const points: PerAnimeDataPoint[] = []; + for (let i = 0; i < titleCount; i += 1) { + points.push({ epochDay: 20_000 + i, animeTitle: `Title ${i}`, value: titleCount - i }); + } + return points; +} + +test('sortTooltipEntries orders rows by value descending, tolerating string values', () => { + const sorted = sortTooltipEntries([ + { name: 'A', value: 5 }, + { name: 'B', value: 20 }, + { name: 'C', value: '12.5' }, + { name: 'D', value: undefined }, + ]); + + assert.deepEqual( + sorted.map((entry) => entry.name), + ['B', 'C', 'A', 'D'], + ); +}); + +test('tooltipColumnCount wraps into extra columns as items grow, capped at 3', () => { + assert.equal(tooltipColumnCount(1), 1); + assert.equal(tooltipColumnCount(8), 1); + assert.equal(tooltipColumnCount(9), 2); + assert.equal(tooltipColumnCount(16), 2); + assert.equal(tooltipColumnCount(17), 3); + assert.equal(tooltipColumnCount(40), 3); +}); + +test('buildLineData keeps every title as a series instead of capping at the top 7', () => { + const { points, seriesKeys } = buildLineData(makePoints(17)); + + assert.equal(seriesKeys.length, 17); + assert.ok(seriesKeys.includes('Title 16')); + for (const row of points) { + for (const key of seriesKeys) { + assert.ok(key in row); + } + } +}); + +test('buildLineData caps series at maxSeries when set, keeping the top titles', () => { + const { points, seriesKeys } = buildLineData(makePoints(17), 7); + + assert.equal(seriesKeys.length, 7); + assert.deepEqual( + seriesKeys, + Array.from({ length: 7 }, (_, i) => `Title ${i}`), + ); + for (const row of points) { + assert.ok(!('Title 16' in row)); + } +}); + +test('buildLineData orders series by total value descending', () => { + const { seriesKeys } = buildLineData([ + { epochDay: 20_000, animeTitle: 'Small', value: 1 }, + { epochDay: 20_000, animeTitle: 'Big', value: 10 }, + { epochDay: 20_001, animeTitle: 'Medium', value: 5 }, + ]); + + assert.deepEqual(seriesKeys, ['Big', 'Medium', 'Small']); +}); + +test('buildLineData total mode ranks by final cumulative value, not the sum of snapshots', () => { + // "Early" plateaus at 10 across four days (snapshot sum 40); "Late" reaches a + // larger final value of 12 in a single day. Ranking by final value keeps Late + // first; the old snapshot-sum ranking would have put Early first. + const raw: PerAnimeDataPoint[] = [ + { epochDay: 20_000, animeTitle: 'Early', value: 10 }, + { epochDay: 20_001, animeTitle: 'Early', value: 10 }, + { epochDay: 20_002, animeTitle: 'Early', value: 10 }, + { epochDay: 20_003, animeTitle: 'Early', value: 10 }, + { epochDay: 20_003, animeTitle: 'Late', value: 12 }, + ]; + + const { seriesKeys } = buildLineData(raw, undefined, 'total'); + assert.deepEqual(seriesKeys, ['Late', 'Early']); +}); + +test('buildLineData recent mode keeps the most recently active titles over the largest', () => { + // "Old Giant" has a huge cumulative total but stopped growing early; "Fresh" + // is small but its cumulative value increased on the latest day. + const raw: PerAnimeDataPoint[] = [ + { epochDay: 20_000, animeTitle: 'Old Giant', value: 500 }, + { epochDay: 20_001, animeTitle: 'Old Giant', value: 500 }, + { epochDay: 20_002, animeTitle: 'Old Giant', value: 500 }, + { epochDay: 20_000, animeTitle: 'Fresh', value: 1 }, + { epochDay: 20_001, animeTitle: 'Fresh', value: 2 }, + { epochDay: 20_002, animeTitle: 'Fresh', value: 5 }, + ]; + + const total = buildLineData(raw, 1, 'total'); + assert.deepEqual(total.seriesKeys, ['Old Giant']); + + const recent = buildLineData(raw, 1, 'recent'); + assert.deepEqual(recent.seriesKeys, ['Fresh']); +}); + +test('buildLineData recent mode breaks ties by total then name', () => { + // Both titles last increased on day 20_002; the larger total wins the tie. + const raw: PerAnimeDataPoint[] = [ + { epochDay: 20_001, animeTitle: 'A', value: 2 }, + { epochDay: 20_002, animeTitle: 'A', value: 4 }, + { epochDay: 20_001, animeTitle: 'B', value: 3 }, + { epochDay: 20_002, animeTitle: 'B', value: 9 }, + ]; + + const { seriesKeys } = buildLineData(raw, 1, 'recent'); + assert.deepEqual(seriesKeys, ['B']); +}); diff --git a/stats/src/components/trends/StackedTrendChart.tsx b/stats/src/components/trends/StackedTrendChart.tsx index c196728d..04f0e58e 100644 --- a/stats/src/components/trends/StackedTrendChart.tsx +++ b/stats/src/components/trends/StackedTrendChart.tsx @@ -20,6 +20,8 @@ interface StackedTrendChartProps { title: string; data: PerAnimeDataPoint[]; colorPalette?: string[]; + maxSeries?: number | null; + maxSeriesMode?: SeriesRankMode; } const DEFAULT_LINE_COLORS = [ @@ -33,19 +35,147 @@ const DEFAULT_LINE_COLORS = [ '#f4dbd6', ]; -function buildLineData(raw: PerAnimeDataPoint[]) { - const totalByAnime = new Map(); +// Wrap the tooltip into extra columns once a single column would get too tall, +// keeping it compact instead of overflowing behind the charts below it. +const TOOLTIP_ROWS_PER_COLUMN = 8; +const TOOLTIP_MAX_COLUMNS = 3; + +export function tooltipColumnCount(itemCount: number): number { + const columns = Math.ceil(itemCount / TOOLTIP_ROWS_PER_COLUMN); + return Math.min(TOOLTIP_MAX_COLUMNS, Math.max(1, columns)); +} + +interface TooltipEntry { + name?: string | number; + value?: string | number; + color?: string; +} + +interface StackedTooltipProps { + active?: boolean; + label?: string | number; + payload?: TooltipEntry[]; +} + +function tooltipEntryValue(entry: TooltipEntry): number { + const numeric = typeof entry.value === 'number' ? entry.value : Number(entry.value); + return Number.isFinite(numeric) ? numeric : 0; +} + +// Tooltip rows are sorted by value (largest first) so the biggest contributors +// read top-down, independent of the series/stacking order. +export function sortTooltipEntries(entries: readonly T[]): T[] { + return [...entries].sort((a, b) => tooltipEntryValue(b) - tooltipEntryValue(a)); +} + +function StackedTooltip({ active, label, payload }: StackedTooltipProps) { + if (!active || !payload || payload.length === 0) { + return null; + } + const sorted = sortTooltipEntries(payload); + const columns = tooltipColumnCount(sorted.length); + return ( +
+ {label !== undefined && ( +
+ {label} +
+ )} +
+ {sorted.map((entry, index) => ( +
+ + {entry.name} + {entry.value} +
+ ))} +
+
+ ); +} + +export type SeriesRankMode = 'total' | 'recent'; + +// Per-title ranking key. The data is cumulative per title, so `total` is the +// final (latest) cumulative value — not the sum of daily snapshots, which would +// over-weight titles that plateaued high early. `recentDay` is the latest epoch +// day the value increased, i.e. its last day of real activity — used to keep +// the most recently watched titles. +function rankTitles(raw: PerAnimeDataPoint[]): Map { + const pointsByTitle = new Map(); for (const entry of raw) { - totalByAnime.set(entry.animeTitle, (totalByAnime.get(entry.animeTitle) ?? 0) + entry.value); + const list = pointsByTitle.get(entry.animeTitle) ?? []; + list.push(entry); + pointsByTitle.set(entry.animeTitle, list); } - const sorted = [...totalByAnime.entries()].sort((a, b) => b[1] - a[1]); - const topTitles = sorted.slice(0, 7).map(([title]) => title); - const topSet = new Set(topTitles); + const stats = new Map(); + for (const [title, points] of pointsByTitle) { + const sorted = [...points].sort((a, b) => a.epochDay - b.epochDay); + let previous = 0; + let recentDay = Number.NEGATIVE_INFINITY; + for (const point of sorted) { + if (point.value > previous) { + recentDay = point.epochDay; + } + previous = point.value; + } + const total = sorted.length > 0 ? sorted[sorted.length - 1]!.value : 0; + stats.set(title, { total, recentDay }); + } + return stats; +} + +export function buildLineData( + raw: PerAnimeDataPoint[], + maxSeries?: number | null, + mode: SeriesRankMode = 'total', +) { + const stats = rankTitles(raw); + + let seriesKeys = [...stats.entries()] + .sort((a, b) => { + if (mode === 'recent') { + return ( + b[1].recentDay - a[1].recentDay || b[1].total - a[1].total || a[0].localeCompare(b[0]) + ); + } + return b[1].total - a[1].total || a[0].localeCompare(b[0]); + }) + .map(([title]) => title); + if (typeof maxSeries === 'number' && maxSeries > 0) { + seriesKeys = seriesKeys.slice(0, maxSeries); + } + const seriesSet = new Set(seriesKeys); const byDay = new Map>(); for (const entry of raw) { - if (!topSet.has(entry.animeTitle)) continue; + if (!seriesSet.has(entry.animeTitle)) continue; const row = byDay.get(entry.epochDay) ?? {}; row[entry.animeTitle] = (row[entry.animeTitle] ?? 0) + Math.round(entry.value * 10) / 10; byDay.set(entry.epochDay, row); @@ -60,17 +190,23 @@ function buildLineData(raw: PerAnimeDataPoint[]) { day: 'numeric', }), }; - for (const title of topTitles) { + for (const title of seriesKeys) { row[title] = values[title] ?? 0; } return row; }); - return { points, seriesKeys: topTitles }; + return { points, seriesKeys }; } -export function StackedTrendChart({ title, data, colorPalette }: StackedTrendChartProps) { - const { points, seriesKeys } = buildLineData(data); +export function StackedTrendChart({ + title, + data, + colorPalette, + maxSeries, + maxSeriesMode, +}: StackedTrendChartProps) { + const { points, seriesKeys } = buildLineData(data, maxSeries, maxSeriesMode); const colors = colorPalette ?? DEFAULT_LINE_COLORS; if (points.length === 0) { @@ -100,7 +236,11 @@ export function StackedTrendChart({ title, data, colorPalette }: StackedTrendCha tickLine={false} width={32} /> - + } + wrapperStyle={{ zIndex: 50 }} + allowEscapeViewBox={{ x: false, y: true }} + /> {seriesKeys.map((key, i) => ( -
+
{seriesKeys.map((key, i) => ( - {key} + {key} ))}
diff --git a/stats/src/components/trends/TrendsTab.test.tsx b/stats/src/components/trends/TrendsTab.test.tsx index e6a55727..7cd5a66b 100644 --- a/stats/src/components/trends/TrendsTab.test.tsx +++ b/stats/src/components/trends/TrendsTab.test.tsx @@ -8,9 +8,13 @@ test('AnimeVisibilityFilter uses title visibility wording', () => { {}} onHideAll={() => {}} onToggleAnime={() => {}} + onMaxTitlesChange={() => {}} + onMaxTitlesModeChange={() => {}} />, ); @@ -18,6 +22,64 @@ test('AnimeVisibilityFilter uses title visibility wording', () => { assert.doesNotMatch(markup, /Anime Visibility/); }); +test('AnimeVisibilityFilter offers a per-chart title limit selector', () => { + const markup = renderToStaticMarkup( + {}} + onHideAll={() => {}} + onToggleAnime={() => {}} + onMaxTitlesChange={() => {}} + onMaxTitlesModeChange={() => {}} + />, + ); + + assert.match(markup, /per chart/); + assert.match(markup, /