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.
This commit is contained in:
2026-07-06 00:29:21 -07:00
parent d618de9d2f
commit 42433dc30b
6 changed files with 243 additions and 22 deletions
@@ -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', () => { test('buildLineData orders series by total value descending', () => {
const { seriesKeys } = buildLineData([ const { seriesKeys } = buildLineData([
{ epochDay: 20_000, animeTitle: 'Small', value: 1 }, { epochDay: 20_000, animeTitle: 'Small', value: 1 },
@@ -20,6 +20,7 @@ interface StackedTrendChartProps {
title: string; title: string;
data: PerAnimeDataPoint[]; data: PerAnimeDataPoint[];
colorPalette?: string[]; colorPalette?: string[];
maxSeries?: number | null;
} }
const DEFAULT_LINE_COLORS = [ const DEFAULT_LINE_COLORS = [
@@ -33,18 +34,23 @@ const DEFAULT_LINE_COLORS = [
'#f4dbd6', '#f4dbd6',
]; ];
export function buildLineData(raw: PerAnimeDataPoint[]) { export function buildLineData(raw: PerAnimeDataPoint[], maxSeries?: number | null) {
const totalByAnime = new Map<string, number>(); const totalByAnime = new Map<string, number>();
for (const entry of raw) { for (const entry of raw) {
totalByAnime.set(entry.animeTitle, (totalByAnime.get(entry.animeTitle) ?? 0) + entry.value); 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])) .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
.map(([title]) => title); .map(([title]) => title);
if (typeof maxSeries === 'number' && maxSeries > 0) {
seriesKeys = seriesKeys.slice(0, maxSeries);
}
const seriesSet = new Set(seriesKeys);
const byDay = new Map<number, Record<string, number>>(); const byDay = new Map<number, Record<string, number>>();
for (const entry of raw) { for (const entry of raw) {
if (!seriesSet.has(entry.animeTitle)) continue;
const row = byDay.get(entry.epochDay) ?? {}; const row = byDay.get(entry.epochDay) ?? {};
row[entry.animeTitle] = (row[entry.animeTitle] ?? 0) + Math.round(entry.value * 10) / 10; row[entry.animeTitle] = (row[entry.animeTitle] ?? 0) + Math.round(entry.value * 10) / 10;
byDay.set(entry.epochDay, row); byDay.set(entry.epochDay, row);
@@ -68,8 +74,13 @@ export function buildLineData(raw: PerAnimeDataPoint[]) {
return { points, seriesKeys }; return { points, seriesKeys };
} }
export function StackedTrendChart({ title, data, colorPalette }: StackedTrendChartProps) { export function StackedTrendChart({
const { points, seriesKeys } = buildLineData(data); title,
data,
colorPalette,
maxSeries,
}: StackedTrendChartProps) {
const { points, seriesKeys } = buildLineData(data, maxSeries);
const colors = colorPalette ?? DEFAULT_LINE_COLORS; const colors = colorPalette ?? DEFAULT_LINE_COLORS;
if (points.length === 0) { if (points.length === 0) {
@@ -8,9 +8,11 @@ test('AnimeVisibilityFilter uses title visibility wording', () => {
<AnimeVisibilityFilter <AnimeVisibilityFilter
animeTitles={['KonoSuba']} animeTitles={['KonoSuba']}
hiddenAnime={new Set()} hiddenAnime={new Set()}
maxTitles={null}
onShowAll={() => {}} onShowAll={() => {}}
onHideAll={() => {}} onHideAll={() => {}}
onToggleAnime={() => {}} onToggleAnime={() => {}}
onMaxTitlesChange={() => {}}
/>, />,
); );
@@ -18,6 +20,24 @@ test('AnimeVisibilityFilter uses title visibility wording', () => {
assert.doesNotMatch(markup, /Anime Visibility/); assert.doesNotMatch(markup, /Anime Visibility/);
}); });
test('AnimeVisibilityFilter offers a per-chart title limit selector', () => {
const markup = renderToStaticMarkup(
<AnimeVisibilityFilter
animeTitles={['KonoSuba']}
hiddenAnime={new Set()}
maxTitles={7}
onShowAll={() => {}}
onHideAll={() => {}}
onToggleAnime={() => {}}
onMaxTitlesChange={() => {}}
/>,
);
assert.match(markup, /per chart/);
assert.match(markup, /<option value="all">All<\/option>/);
assert.match(markup, /<option value="7" selected="">/);
});
test('TrendsTab source labels words per minute without reading speed wording', async () => { test('TrendsTab source labels words per minute without reading speed wording', async () => {
const source = await Bun.file(new URL('./TrendsTab.tsx', import.meta.url)).text(); const source = await Bun.file(new URL('./TrendsTab.tsx', import.meta.url)).text();
+68 -18
View File
@@ -4,9 +4,14 @@ import { DateRangeSelector } from './DateRangeSelector';
import { TrendChart } from './TrendChart'; import { TrendChart } from './TrendChart';
import { StackedTrendChart } from './StackedTrendChart'; import { StackedTrendChart } from './StackedTrendChart';
import { import {
MAX_TITLES_OPTIONS,
buildAnimeVisibilityOptions, buildAnimeVisibilityOptions,
filterHiddenAnimeData, filterHiddenAnimeData,
loadHiddenTitles,
loadMaxTitles,
pruneHiddenAnime, pruneHiddenAnime,
saveHiddenTitles,
saveMaxTitles,
} from './anime-visibility'; } from './anime-visibility';
import { LibrarySummarySection } from './LibrarySummarySection'; import { LibrarySummarySection } from './LibrarySummarySection';
@@ -24,17 +29,21 @@ function SectionHeader({ children }: { children: React.ReactNode }) {
interface AnimeVisibilityFilterProps { interface AnimeVisibilityFilterProps {
animeTitles: string[]; animeTitles: string[];
hiddenAnime: ReadonlySet<string>; hiddenAnime: ReadonlySet<string>;
maxTitles: number | null;
onShowAll: () => void; onShowAll: () => void;
onHideAll: () => void; onHideAll: () => void;
onToggleAnime: (title: string) => void; onToggleAnime: (title: string) => void;
onMaxTitlesChange: (value: number | null) => void;
} }
export function AnimeVisibilityFilter({ export function AnimeVisibilityFilter({
animeTitles, animeTitles,
hiddenAnime, hiddenAnime,
maxTitles,
onShowAll, onShowAll,
onHideAll, onHideAll,
onToggleAnime, onToggleAnime,
onMaxTitlesChange,
}: AnimeVisibilityFilterProps) { }: AnimeVisibilityFilterProps) {
if (animeTitles.length === 0) { if (animeTitles.length === 0) {
return null; return null;
@@ -48,10 +57,28 @@ export function AnimeVisibilityFilter({
Title Visibility Title Visibility
</h4> </h4>
<p className="mt-1 text-xs text-ctp-overlay1"> <p className="mt-1 text-xs text-ctp-overlay1">
Shared across all anime trend charts. Default: show everything. Shared across all anime trend charts. Default: show everything. Selections are saved.
</p> </p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<label className="flex items-center gap-1 text-[11px] text-ctp-subtext0">
Top
<select
className="rounded-md border border-ctp-surface2 bg-ctp-surface0 px-1.5 py-1 text-[11px] font-medium text-ctp-text transition hover:border-ctp-blue"
value={maxTitles === null ? 'all' : String(maxTitles)}
onChange={(event) =>
onMaxTitlesChange(event.target.value === 'all' ? null : Number(event.target.value))
}
>
<option value="all">All</option>
{MAX_TITLES_OPTIONS.map((count) => (
<option key={count} value={count}>
{count}
</option>
))}
</select>
per chart
</label>
<button <button
type="button" type="button"
className="rounded-md border border-ctp-surface2 px-2 py-1 text-[11px] font-medium text-ctp-text transition hover:border-ctp-blue hover:text-ctp-blue" className="rounded-md border border-ctp-surface2 px-2 py-1 text-[11px] font-medium text-ctp-text transition hover:border-ctp-blue hover:text-ctp-blue"
@@ -96,8 +123,18 @@ export function AnimeVisibilityFilter({
export function TrendsTab() { export function TrendsTab() {
const [range, setRange] = useState<TimeRange>('30d'); const [range, setRange] = useState<TimeRange>('30d');
const [groupBy, setGroupBy] = useState<GroupBy>('day'); const [groupBy, setGroupBy] = useState<GroupBy>('day');
const [hiddenAnime, setHiddenAnime] = useState<Set<string>>(() => new Set()); const [hiddenAnime, setHiddenAnime] = useState<Set<string>>(() => loadHiddenTitles());
const [maxTitles, setMaxTitles] = useState<number | null>(() => loadMaxTitles());
const { data, loading, error } = useTrends(range, groupBy); const { data, loading, error } = useTrends(range, groupBy);
const updateHiddenAnime = (next: Set<string>) => {
setHiddenAnime(next);
saveHiddenTitles(next);
};
const updateMaxTitles = (value: number | null) => {
setMaxTitles(value);
saveMaxTitles(value);
};
const cardsMinedColor = 'var(--color-ctp-cards-mined)'; const cardsMinedColor = 'var(--color-ctp-cards-mined)';
const cardsMinedStackedColors = [ const cardsMinedStackedColors = [
cardsMinedColor, cardsMinedColor,
@@ -246,28 +283,41 @@ export function TrendsTab() {
<AnimeVisibilityFilter <AnimeVisibilityFilter
animeTitles={animeTitles} animeTitles={animeTitles}
hiddenAnime={activeHiddenAnime} hiddenAnime={activeHiddenAnime}
onShowAll={() => setHiddenAnime(new Set())} maxTitles={maxTitles}
onHideAll={() => setHiddenAnime(new Set(animeTitles))} onShowAll={() => updateHiddenAnime(new Set())}
onToggleAnime={(title) => onHideAll={() => updateHiddenAnime(new Set(animeTitles))}
setHiddenAnime((current) => { onToggleAnime={(title) => {
const next = new Set(current); const next = new Set(hiddenAnime);
if (next.has(title)) { if (next.has(title)) {
next.delete(title); next.delete(title);
} else { } else {
next.add(title); next.add(title);
} }
return next; updateHiddenAnime(next);
}) }}
} onMaxTitlesChange={updateMaxTitles}
/>
<StackedTrendChart
title="Watch Time Progress (min)"
data={filteredWatchTimeProgress}
maxSeries={maxTitles}
/>
<StackedTrendChart
title="Episodes Progress"
data={filteredAnimeProgress}
maxSeries={maxTitles}
/> />
<StackedTrendChart title="Watch Time Progress (min)" data={filteredWatchTimeProgress} />
<StackedTrendChart title="Episodes Progress" data={filteredAnimeProgress} />
<StackedTrendChart <StackedTrendChart
title="Cards Mined Progress" title="Cards Mined Progress"
data={filteredCardsProgress} data={filteredCardsProgress}
colorPalette={cardsMinedStackedColors} colorPalette={cardsMinedStackedColors}
maxSeries={maxTitles}
/>
<StackedTrendChart
title="Words Seen Progress"
data={filteredWordsProgress}
maxSeries={maxTitles}
/> />
<StackedTrendChart title="Words Seen Progress" data={filteredWordsProgress} />
<SectionHeader>Library Summary</SectionHeader> <SectionHeader>Library Summary</SectionHeader>
<LibrarySummarySection rows={data.librarySummary} hiddenTitles={activeHiddenAnime} /> <LibrarySummarySection rows={data.librarySummary} hiddenTitles={activeHiddenAnime} />
@@ -5,9 +5,36 @@ import type { PerAnimeDataPoint } from './StackedTrendChart';
import { import {
buildAnimeVisibilityOptions, buildAnimeVisibilityOptions,
filterHiddenAnimeData, filterHiddenAnimeData,
loadHiddenTitles,
loadMaxTitles,
pruneHiddenAnime, pruneHiddenAnime,
saveHiddenTitles,
saveMaxTitles,
} from './anime-visibility'; } from './anime-visibility';
function installLocalStorage(initial: Record<string, string> = {}) {
const previous = Object.getOwnPropertyDescriptor(globalThis, 'localStorage');
const values = new Map(Object.entries(initial));
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: {
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => values.set(key, value),
removeItem: (key: string) => values.delete(key),
},
});
return {
values,
restore: () => {
if (previous) {
Object.defineProperty(globalThis, 'localStorage', previous);
} else {
delete (globalThis as { localStorage?: unknown }).localStorage;
}
},
};
}
const SAMPLE_POINTS: PerAnimeDataPoint[] = [ const SAMPLE_POINTS: PerAnimeDataPoint[] = [
{ epochDay: 1, animeTitle: 'KonoSuba', value: 5 }, { epochDay: 1, animeTitle: 'KonoSuba', value: 5 },
{ epochDay: 2, animeTitle: 'KonoSuba', value: 10 }, { epochDay: 2, animeTitle: 'KonoSuba', value: 10 },
@@ -45,3 +72,46 @@ test('pruneHiddenAnime drops titles that are no longer available', () => {
assert.deepEqual([...hidden], ['KonoSuba']); assert.deepEqual([...hidden], ['KonoSuba']);
}); });
test('hidden titles round-trip through localStorage', () => {
const { restore } = installLocalStorage();
try {
saveHiddenTitles(new Set(['KonoSuba', 'One Piece']));
assert.deepEqual([...loadHiddenTitles()], ['KonoSuba', 'One Piece']);
} finally {
restore();
}
});
test('loadHiddenTitles tolerates missing or malformed stored values', () => {
const { restore } = installLocalStorage({
'subminer-stats-trends-hidden-titles': '{"not":"an array"}',
});
try {
assert.deepEqual([...loadHiddenTitles()], []);
} finally {
restore();
}
});
test('max titles preference round-trips and clears when set to null', () => {
const { values, restore } = installLocalStorage();
try {
saveMaxTitles(7);
assert.equal(loadMaxTitles(), 7);
saveMaxTitles(null);
assert.equal(loadMaxTitles(), null);
assert.equal(values.has('subminer-stats-trends-max-titles'), false);
} finally {
restore();
}
});
test('loadMaxTitles rejects non-positive or non-numeric stored values', () => {
const { restore } = installLocalStorage({ 'subminer-stats-trends-max-titles': 'banana' });
try {
assert.equal(loadMaxTitles(), null);
} finally {
restore();
}
});
@@ -1,5 +1,62 @@
import type { PerAnimeDataPoint } from './StackedTrendChart'; import type { PerAnimeDataPoint } from './StackedTrendChart';
const HIDDEN_TITLES_KEY = 'subminer-stats-trends-hidden-titles';
const MAX_TITLES_KEY = 'subminer-stats-trends-max-titles';
export const MAX_TITLES_OPTIONS = [3, 5, 7, 10] as const;
function getStorage(): Storage | null {
try {
return globalThis.localStorage ?? null;
} catch {
return null;
}
}
export function loadHiddenTitles(): Set<string> {
try {
const raw = getStorage()?.getItem(HIDDEN_TITLES_KEY);
const parsed: unknown = raw ? JSON.parse(raw) : [];
if (!Array.isArray(parsed)) return new Set();
return new Set(parsed.filter((value): value is string => typeof value === 'string'));
} catch {
return new Set();
}
}
export function saveHiddenTitles(hidden: ReadonlySet<string>): void {
try {
getStorage()?.setItem(HIDDEN_TITLES_KEY, JSON.stringify([...hidden]));
} catch {
// Storage can be blocked in private/restricted contexts; keep the in-memory choice.
}
}
export function loadMaxTitles(): number | null {
try {
const raw = getStorage()?.getItem(MAX_TITLES_KEY);
if (raw === null || raw === undefined) return null;
const value = Number(raw);
return Number.isInteger(value) && value > 0 ? value : null;
} catch {
return null;
}
}
export function saveMaxTitles(value: number | null): void {
try {
const storage = getStorage();
if (!storage) return;
if (value === null) {
storage.removeItem(MAX_TITLES_KEY);
} else {
storage.setItem(MAX_TITLES_KEY, String(value));
}
} catch {
// Storage can be blocked in private/restricted contexts; keep the in-memory choice.
}
}
export function buildAnimeVisibilityOptions(datasets: PerAnimeDataPoint[][]): string[] { export function buildAnimeVisibilityOptions(datasets: PerAnimeDataPoint[][]): string[] {
const totals = new Map<string, number>(); const totals = new Map<string, number>();
for (const dataset of datasets) { for (const dataset of datasets) {