mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-07-28 16:49:50 -07:00
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:
@@ -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();
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user