From 42433dc30b1d0ea1a731f6e696ed7692ddc70bff Mon Sep 17 00:00:00 2001 From: sudacode Date: Mon, 6 Jul 2026 00:29:21 -0700 Subject: [PATCH] feat(stats): persist trend title visibility and add per-chart title limit Hidden-title selections in the Trends tab now survive relaunches via localStorage, and a 'Top N per chart' selector (All/3/5/7/10, also persisted) restores the old top-N declutter behavior on demand. --- .../trends/StackedTrendChart.test.ts | 13 +++ .../components/trends/StackedTrendChart.tsx | 19 +++- .../src/components/trends/TrendsTab.test.tsx | 20 +++++ stats/src/components/trends/TrendsTab.tsx | 86 +++++++++++++++---- .../trends/anime-visibility.test.ts | 70 +++++++++++++++ .../src/components/trends/anime-visibility.ts | 57 ++++++++++++ 6 files changed, 243 insertions(+), 22 deletions(-) diff --git a/stats/src/components/trends/StackedTrendChart.test.ts b/stats/src/components/trends/StackedTrendChart.test.ts index 7902389e..142a8425 100644 --- a/stats/src/components/trends/StackedTrendChart.test.ts +++ b/stats/src/components/trends/StackedTrendChart.test.ts @@ -22,6 +22,19 @@ test('buildLineData keeps every title as a series instead of capping at the top } }); +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 }, diff --git a/stats/src/components/trends/StackedTrendChart.tsx b/stats/src/components/trends/StackedTrendChart.tsx index 5b3b634e..71eb1d85 100644 --- a/stats/src/components/trends/StackedTrendChart.tsx +++ b/stats/src/components/trends/StackedTrendChart.tsx @@ -20,6 +20,7 @@ interface StackedTrendChartProps { title: string; data: PerAnimeDataPoint[]; colorPalette?: string[]; + maxSeries?: number | null; } const DEFAULT_LINE_COLORS = [ @@ -33,18 +34,23 @@ const DEFAULT_LINE_COLORS = [ '#f4dbd6', ]; -export function buildLineData(raw: PerAnimeDataPoint[]) { +export function buildLineData(raw: PerAnimeDataPoint[], maxSeries?: number | null) { const totalByAnime = new Map(); for (const entry of raw) { totalByAnime.set(entry.animeTitle, (totalByAnime.get(entry.animeTitle) ?? 0) + entry.value); } - const seriesKeys = [...totalByAnime.entries()] + let seriesKeys = [...totalByAnime.entries()] .sort((a, b) => b[1] - a[1] || 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 (!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); @@ -68,8 +74,13 @@ export function buildLineData(raw: PerAnimeDataPoint[]) { return { points, seriesKeys }; } -export function StackedTrendChart({ title, data, colorPalette }: StackedTrendChartProps) { - const { points, seriesKeys } = buildLineData(data); +export function StackedTrendChart({ + title, + data, + colorPalette, + maxSeries, +}: StackedTrendChartProps) { + const { points, seriesKeys } = buildLineData(data, maxSeries); const colors = colorPalette ?? DEFAULT_LINE_COLORS; if (points.length === 0) { diff --git a/stats/src/components/trends/TrendsTab.test.tsx b/stats/src/components/trends/TrendsTab.test.tsx index e6a55727..23b0a34c 100644 --- a/stats/src/components/trends/TrendsTab.test.tsx +++ b/stats/src/components/trends/TrendsTab.test.tsx @@ -8,9 +8,11 @@ test('AnimeVisibilityFilter uses title visibility wording', () => { {}} onHideAll={() => {}} onToggleAnime={() => {}} + onMaxTitlesChange={() => {}} />, ); @@ -18,6 +20,24 @@ 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={() => {}} + />, + ); + + assert.match(markup, /per chart/); + assert.match(markup, /