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', () => {
const { seriesKeys } = buildLineData([
{ epochDay: 20_000, animeTitle: 'Small', value: 1 },
@@ -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<string, number>();
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<number, Record<string, number>>();
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) {
@@ -8,9 +8,11 @@ test('AnimeVisibilityFilter uses title visibility wording', () => {
<AnimeVisibilityFilter
animeTitles={['KonoSuba']}
hiddenAnime={new Set()}
maxTitles={null}
onShowAll={() => {}}
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(
<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 () => {
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 { StackedTrendChart } from './StackedTrendChart';
import {
MAX_TITLES_OPTIONS,
buildAnimeVisibilityOptions,
filterHiddenAnimeData,
loadHiddenTitles,
loadMaxTitles,
pruneHiddenAnime,
saveHiddenTitles,
saveMaxTitles,
} from './anime-visibility';
import { LibrarySummarySection } from './LibrarySummarySection';
@@ -24,17 +29,21 @@ function SectionHeader({ children }: { children: React.ReactNode }) {
interface AnimeVisibilityFilterProps {
animeTitles: string[];
hiddenAnime: ReadonlySet<string>;
maxTitles: number | null;
onShowAll: () => void;
onHideAll: () => void;
onToggleAnime: (title: string) => void;
onMaxTitlesChange: (value: number | null) => void;
}
export function AnimeVisibilityFilter({
animeTitles,
hiddenAnime,
maxTitles,
onShowAll,
onHideAll,
onToggleAnime,
onMaxTitlesChange,
}: AnimeVisibilityFilterProps) {
if (animeTitles.length === 0) {
return null;
@@ -48,10 +57,28 @@ export function AnimeVisibilityFilter({
Title Visibility
</h4>
<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>
</div>
<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
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"
@@ -96,8 +123,18 @@ export function AnimeVisibilityFilter({
export function TrendsTab() {
const [range, setRange] = useState<TimeRange>('30d');
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 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 cardsMinedStackedColors = [
cardsMinedColor,
@@ -246,28 +283,41 @@ export function TrendsTab() {
<AnimeVisibilityFilter
animeTitles={animeTitles}
hiddenAnime={activeHiddenAnime}
onShowAll={() => setHiddenAnime(new Set())}
onHideAll={() => setHiddenAnime(new Set(animeTitles))}
onToggleAnime={(title) =>
setHiddenAnime((current) => {
const next = new Set(current);
if (next.has(title)) {
next.delete(title);
} else {
next.add(title);
}
return next;
})
}
maxTitles={maxTitles}
onShowAll={() => updateHiddenAnime(new Set())}
onHideAll={() => updateHiddenAnime(new Set(animeTitles))}
onToggleAnime={(title) => {
const next = new Set(hiddenAnime);
if (next.has(title)) {
next.delete(title);
} else {
next.add(title);
}
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
title="Cards Mined Progress"
data={filteredCardsProgress}
colorPalette={cardsMinedStackedColors}
maxSeries={maxTitles}
/>
<StackedTrendChart
title="Words Seen Progress"
data={filteredWordsProgress}
maxSeries={maxTitles}
/>
<StackedTrendChart title="Words Seen Progress" data={filteredWordsProgress} />
<SectionHeader>Library Summary</SectionHeader>
<LibrarySummarySection rows={data.librarySummary} hiddenTitles={activeHiddenAnime} />
@@ -5,9 +5,36 @@ import type { PerAnimeDataPoint } from './StackedTrendChart';
import {
buildAnimeVisibilityOptions,
filterHiddenAnimeData,
loadHiddenTitles,
loadMaxTitles,
pruneHiddenAnime,
saveHiddenTitles,
saveMaxTitles,
} 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[] = [
{ epochDay: 1, animeTitle: 'KonoSuba', value: 5 },
{ epochDay: 2, animeTitle: 'KonoSuba', value: 10 },
@@ -45,3 +72,46 @@ test('pruneHiddenAnime drops titles that are no longer available', () => {
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';
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[] {
const totals = new Map<string, number>();
for (const dataset of datasets) {