feat(stats): Trends dashboard overhaul — title visibility, ranking modes, calendar-accurate windows, tooltips (#140)

This commit is contained in:
2026-07-06 23:52:43 -07:00
committed by GitHub
parent 48a084914a
commit 8b9a70c5a6
16 changed files with 969 additions and 135 deletions
@@ -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<T extends string>({
@@ -46,11 +48,13 @@ function SegmentedControl<T extends string>({
export function DateRangeSelector({
range,
groupBy,
fillEmpty,
onRangeChange,
onGroupByChange,
onFillEmptyChange,
}: DateRangeSelectorProps) {
return (
<div className="flex items-center gap-4 text-sm">
<div className="flex flex-wrap items-center gap-4 text-sm">
<SegmentedControl
label="Range"
options={['7d', '30d', '90d', '365d', 'all'] as TimeRange[]}
@@ -65,6 +69,13 @@ export function DateRangeSelector({
onChange={onGroupByChange}
formatLabel={(g) => g.charAt(0).toUpperCase() + g.slice(1)}
/>
<SegmentedControl
label="Empty days"
options={['show', 'hide']}
value={fillEmpty ? 'show' : 'hide'}
onChange={(v) => onFillEmptyChange(v === 'show')}
formatLabel={(v) => (v === 'show' ? 'Show' : 'Hide')}
/>
</div>
);
}
@@ -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']);
});
+155 -15
View File
@@ -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<string, number>();
// 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<T extends TooltipEntry>(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 (
<div
style={{
...TOOLTIP_CONTENT_STYLE,
padding: '6px 10px',
maxWidth: '90vw',
}}
>
{label !== undefined && (
<div style={{ color: CHART_THEME.tooltipLabel, marginBottom: 4, fontWeight: 600 }}>
{label}
</div>
)}
<div
style={{
display: 'grid',
gridTemplateColumns: `repeat(${columns}, max-content)`,
columnGap: 16,
rowGap: 2,
}}
>
{sorted.map((entry, index) => (
<div
key={`${entry.name ?? index}`}
style={{ display: 'flex', alignItems: 'center', gap: 6, whiteSpace: 'nowrap' }}
>
<span
style={{
display: 'inline-block',
width: 8,
height: 8,
borderRadius: 9999,
background: entry.color,
flexShrink: 0,
}}
/>
<span>{entry.name}</span>
<span style={{ marginLeft: 16, color: CHART_THEME.tooltipLabel }}>{entry.value}</span>
</div>
))}
</div>
</div>
);
}
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<string, { total: number; recentDay: number }> {
const pointsByTitle = new Map<string, PerAnimeDataPoint[]>();
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<string, { total: number; recentDay: number }>();
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<number, Record<string, number>>();
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}
/>
<Tooltip contentStyle={TOOLTIP_CONTENT_STYLE} />
<Tooltip
content={<StackedTooltip />}
wrapperStyle={{ zIndex: 50 }}
allowEscapeViewBox={{ x: false, y: true }}
/>
{seriesKeys.map((key, i) => (
<Area
key={key}
@@ -115,18 +255,18 @@ export function StackedTrendChart({ title, data, colorPalette }: StackedTrendCha
))}
</AreaChart>
</ResponsiveContainer>
<div className="flex flex-wrap gap-x-3 gap-y-1 mt-2 overflow-hidden max-h-10">
<div className="flex flex-wrap gap-x-3 gap-y-1 mt-2">
{seriesKeys.map((key, i) => (
<span
key={key}
className="flex items-center gap-1 text-[10px] text-ctp-subtext0 max-w-[140px]"
className="flex items-center gap-1 text-[10px] text-ctp-subtext0 whitespace-nowrap"
title={key}
>
<span
className="inline-block w-2 h-2 rounded-full shrink-0"
style={{ backgroundColor: colors[i % colors.length] }}
/>
<span className="truncate">{key}</span>
<span>{key}</span>
</span>
))}
</div>
@@ -8,9 +8,13 @@ test('AnimeVisibilityFilter uses title visibility wording', () => {
<AnimeVisibilityFilter
animeTitles={['KonoSuba']}
hiddenAnime={new Set()}
maxTitles={null}
maxTitlesMode="total"
onShowAll={() => {}}
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(
<AnimeVisibilityFilter
animeTitles={['KonoSuba']}
hiddenAnime={new Set()}
maxTitles={7}
maxTitlesMode="total"
onShowAll={() => {}}
onHideAll={() => {}}
onToggleAnime={() => {}}
onMaxTitlesChange={() => {}}
onMaxTitlesModeChange={() => {}}
/>,
);
assert.match(markup, /per chart/);
assert.match(markup, /<option value="all">All<\/option>/);
assert.match(markup, /<option value="7" selected="">/);
});
test('AnimeVisibilityFilter offers top vs most-recent ranking modes', () => {
const markup = renderToStaticMarkup(
<AnimeVisibilityFilter
animeTitles={['KonoSuba']}
hiddenAnime={new Set()}
maxTitles={7}
maxTitlesMode="recent"
onShowAll={() => {}}
onHideAll={() => {}}
onToggleAnime={() => {}}
onMaxTitlesChange={() => {}}
onMaxTitlesModeChange={() => {}}
/>,
);
assert.match(markup, /<option value="total">top<\/option>/);
assert.match(markup, /<option value="recent" selected="">most recent<\/option>/);
});
test('AnimeVisibilityFilter keeps the ranking mode selectable even when showing all titles', () => {
const markup = renderToStaticMarkup(
<AnimeVisibilityFilter
animeTitles={['KonoSuba']}
hiddenAnime={new Set()}
maxTitles={null}
maxTitlesMode="total"
onShowAll={() => {}}
onHideAll={() => {}}
onToggleAnime={() => {}}
onMaxTitlesChange={() => {}}
onMaxTitlesModeChange={() => {}}
/>,
);
assert.match(markup, /aria-label="Title ranking mode"/);
assert.doesNotMatch(markup, /aria-label="Title ranking mode"[^>]*disabled/);
});
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();
+115 -19
View File
@@ -4,12 +4,28 @@ import { DateRangeSelector } from './DateRangeSelector';
import { TrendChart } from './TrendChart';
import { StackedTrendChart } from './StackedTrendChart';
import {
MAX_TITLES_MODES,
MAX_TITLES_OPTIONS,
buildAnimeVisibilityOptions,
filterHiddenAnimeData,
loadHiddenTitles,
loadMaxTitles,
loadMaxTitlesMode,
loadShowEmptyDays,
pruneHiddenAnime,
saveHiddenTitles,
saveMaxTitles,
saveMaxTitlesMode,
saveShowEmptyDays,
type MaxTitlesMode,
} from './anime-visibility';
import { LibrarySummarySection } from './LibrarySummarySection';
const MAX_TITLES_MODE_LABELS: Record<MaxTitlesMode, string> = {
recent: 'most recent',
total: 'top',
};
function SectionHeader({ children }: { children: React.ReactNode }) {
return (
<div className="col-span-full mt-6 mb-2 flex items-center gap-3">
@@ -24,17 +40,25 @@ function SectionHeader({ children }: { children: React.ReactNode }) {
interface AnimeVisibilityFilterProps {
animeTitles: string[];
hiddenAnime: ReadonlySet<string>;
maxTitles: number | null;
maxTitlesMode: MaxTitlesMode;
onShowAll: () => void;
onHideAll: () => void;
onToggleAnime: (title: string) => void;
onMaxTitlesChange: (value: number | null) => void;
onMaxTitlesModeChange: (mode: MaxTitlesMode) => void;
}
export function AnimeVisibilityFilter({
animeTitles,
hiddenAnime,
maxTitles,
maxTitlesMode,
onShowAll,
onHideAll,
onToggleAnime,
onMaxTitlesChange,
onMaxTitlesModeChange,
}: AnimeVisibilityFilterProps) {
if (animeTitles.length === 0) {
return null;
@@ -48,10 +72,41 @@ 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">
Show
<select
aria-label="Title ranking mode"
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={maxTitlesMode}
onChange={(event) => onMaxTitlesModeChange(event.target.value as MaxTitlesMode)}
>
{MAX_TITLES_MODES.map((mode) => (
<option key={mode} value={mode}>
{MAX_TITLES_MODE_LABELS[mode]}
</option>
))}
</select>
<select
aria-label="Titles per chart"
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 +151,28 @@ 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 { data, loading, error } = useTrends(range, groupBy);
const [showEmptyDays, setShowEmptyDays] = useState(() => loadShowEmptyDays());
const [hiddenAnime, setHiddenAnime] = useState<Set<string>>(() => loadHiddenTitles());
const [maxTitles, setMaxTitles] = useState<number | null>(() => loadMaxTitles());
const [maxTitlesMode, setMaxTitlesMode] = useState<MaxTitlesMode>(() => loadMaxTitlesMode());
const { data, loading, error } = useTrends(range, groupBy, showEmptyDays);
const updateHiddenAnime = (next: Set<string>) => {
setHiddenAnime(next);
saveHiddenTitles(next);
};
const updateMaxTitles = (value: number | null) => {
setMaxTitles(value);
saveMaxTitles(value);
};
const updateMaxTitlesMode = (mode: MaxTitlesMode) => {
setMaxTitlesMode(mode);
saveMaxTitlesMode(mode);
};
const updateShowEmptyDays = (show: boolean) => {
setShowEmptyDays(show);
saveShowEmptyDays(show);
};
const cardsMinedColor = 'var(--color-ctp-cards-mined)';
const cardsMinedStackedColors = [
cardsMinedColor,
@@ -144,8 +219,10 @@ export function TrendsTab() {
<DateRangeSelector
range={range}
groupBy={groupBy}
fillEmpty={showEmptyDays}
onRangeChange={setRange}
onGroupByChange={setGroupBy}
onFillEmptyChange={updateShowEmptyDays}
/>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<SectionHeader>Activity (per {groupBy === 'month' ? 'month' : 'day'})</SectionHeader>
@@ -246,28 +323,47 @@ 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}
maxTitlesMode={maxTitlesMode}
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}
onMaxTitlesModeChange={updateMaxTitlesMode}
/>
<StackedTrendChart
title="Watch Time Progress (min)"
data={filteredWatchTimeProgress}
maxSeries={maxTitles}
maxSeriesMode={maxTitlesMode}
/>
<StackedTrendChart
title="Episodes Progress"
data={filteredAnimeProgress}
maxSeries={maxTitles}
maxSeriesMode={maxTitlesMode}
/>
<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}
maxSeriesMode={maxTitlesMode}
/>
<StackedTrendChart
title="Words Seen Progress"
data={filteredWordsProgress}
maxSeries={maxTitles}
maxSeriesMode={maxTitlesMode}
/>
<StackedTrendChart title="Words Seen Progress" data={filteredWordsProgress} />
<SectionHeader>Library Summary</SectionHeader>
<LibrarySummarySection rows={data.librarySummary} hiddenTitles={activeHiddenAnime} />
@@ -5,9 +5,40 @@ import type { PerAnimeDataPoint } from './StackedTrendChart';
import {
buildAnimeVisibilityOptions,
filterHiddenAnimeData,
loadHiddenTitles,
loadMaxTitles,
loadMaxTitlesMode,
loadShowEmptyDays,
pruneHiddenAnime,
saveHiddenTitles,
saveMaxTitles,
saveMaxTitlesMode,
saveShowEmptyDays,
} 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 +76,86 @@ 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 defaults to 7 and round-trips including explicit All', () => {
const { values, restore } = installLocalStorage();
try {
// First run (nothing stored) defaults to the 7-title cap, not "All".
assert.equal(loadMaxTitles(), 7);
saveMaxTitles(5);
assert.equal(loadMaxTitles(), 5);
// "All" persists explicitly instead of collapsing back to the default.
saveMaxTitles(null);
assert.equal(loadMaxTitles(), null);
assert.equal(values.get('subminer-stats-trends-max-titles'), 'all');
} finally {
restore();
}
});
test('loadMaxTitles falls back to the default for unsupported stored values', () => {
for (const storedValue of ['8', '0', 'banana']) {
const { restore } = installLocalStorage({ 'subminer-stats-trends-max-titles': storedValue });
try {
assert.equal(loadMaxTitles(), 7);
} finally {
restore();
}
}
});
test('max titles mode defaults to recent and round-trips', () => {
const { restore } = installLocalStorage();
try {
assert.equal(loadMaxTitlesMode(), 'recent');
saveMaxTitlesMode('total');
assert.equal(loadMaxTitlesMode(), 'total');
saveMaxTitlesMode('recent');
assert.equal(loadMaxTitlesMode(), 'recent');
} finally {
restore();
}
});
test('loadMaxTitlesMode falls back to recent for unknown stored values', () => {
const { restore } = installLocalStorage({ 'subminer-stats-trends-max-titles-mode': 'sideways' });
try {
assert.equal(loadMaxTitlesMode(), 'recent');
} finally {
restore();
}
});
test('show empty days preference defaults to true and round-trips', () => {
const { restore } = installLocalStorage();
try {
assert.equal(loadShowEmptyDays(), true);
saveShowEmptyDays(false);
assert.equal(loadShowEmptyDays(), false);
saveShowEmptyDays(true);
assert.equal(loadShowEmptyDays(), true);
} finally {
restore();
}
});
@@ -1,5 +1,109 @@
import type { PerAnimeDataPoint } from './StackedTrendChart';
const HIDDEN_TITLES_KEY = 'subminer-stats-trends-hidden-titles';
const MAX_TITLES_KEY = 'subminer-stats-trends-max-titles';
const MAX_TITLES_MODE_KEY = 'subminer-stats-trends-max-titles-mode';
const SHOW_EMPTY_DAYS_KEY = 'subminer-stats-trends-show-empty-days';
export const MAX_TITLES_OPTIONS = [3, 5, 7, 10] as const;
// How the per-chart limit picks which titles survive: 'recent' keeps the most
// recently active titles, 'total' keeps the highest cumulative totals. 'recent'
// is listed first so it heads the dropdown.
export type MaxTitlesMode = 'total' | 'recent';
export const MAX_TITLES_MODES: readonly MaxTitlesMode[] = ['recent', 'total'];
// First-run defaults: show the 7 most recently active titles per chart.
const DEFAULT_MAX_TITLES = 7;
const DEFAULT_MAX_TITLES_MODE: MaxTitlesMode = 'recent';
const ALL_TITLES_STORED_VALUE = 'all';
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.
}
}
// Returns the persisted per-chart limit: a specific count, or null for "All".
// Absent/garbage storage falls back to the default count (never null), so a
// first run shows the default cap rather than every title. "All" is stored
// explicitly so it round-trips instead of collapsing back to the default.
export function loadMaxTitles(): number | null {
try {
const raw = getStorage()?.getItem(MAX_TITLES_KEY);
if (raw === ALL_TITLES_STORED_VALUE) return null;
const value = Number(raw);
return (MAX_TITLES_OPTIONS as readonly number[]).includes(value) ? value : DEFAULT_MAX_TITLES;
} catch {
return DEFAULT_MAX_TITLES;
}
}
export function saveMaxTitles(value: number | null): void {
try {
getStorage()?.setItem(MAX_TITLES_KEY, value === null ? ALL_TITLES_STORED_VALUE : String(value));
} catch {
// Storage can be blocked in private/restricted contexts; keep the in-memory choice.
}
}
export function loadMaxTitlesMode(): MaxTitlesMode {
try {
const raw = getStorage()?.getItem(MAX_TITLES_MODE_KEY);
if (raw === 'recent' || raw === 'total') return raw;
return DEFAULT_MAX_TITLES_MODE;
} catch {
return DEFAULT_MAX_TITLES_MODE;
}
}
export function saveMaxTitlesMode(mode: MaxTitlesMode): void {
try {
getStorage()?.setItem(MAX_TITLES_MODE_KEY, mode);
} catch {
// Storage can be blocked in private/restricted contexts; keep the in-memory choice.
}
}
// Whether trend charts zero-fill empty calendar buckets. Defaults to true so a
// first run shows the calendar-accurate view.
export function loadShowEmptyDays(): boolean {
try {
return getStorage()?.getItem(SHOW_EMPTY_DAYS_KEY) !== 'false';
} catch {
return true;
}
}
export function saveShowEmptyDays(show: boolean): void {
try {
getStorage()?.setItem(SHOW_EMPTY_DAYS_KEY, show ? 'true' : 'false');
} 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) {
+3 -3
View File
@@ -5,7 +5,7 @@ import type { TrendsDashboardData } from '../types/stats';
export type TimeRange = '7d' | '30d' | '90d' | '365d' | 'all';
export type GroupBy = 'day' | 'month';
export function useTrends(range: TimeRange, groupBy: GroupBy) {
export function useTrends(range: TimeRange, groupBy: GroupBy, fillEmpty = true) {
const [data, setData] = useState<TrendsDashboardData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -15,7 +15,7 @@ export function useTrends(range: TimeRange, groupBy: GroupBy) {
setLoading(true);
setError(null);
getStatsClient()
.getTrendsDashboard(range, groupBy)
.getTrendsDashboard(range, groupBy, fillEmpty)
.then((nextData) => {
if (cancelled) return;
setData(nextData);
@@ -31,7 +31,7 @@ export function useTrends(range: TimeRange, groupBy: GroupBy) {
return () => {
cancelled = true;
};
}, [range, groupBy]);
}, [range, groupBy, fillEmpty]);
return { data, loading, error };
}
+9 -3
View File
@@ -167,7 +167,10 @@ test('getTrendsDashboard requests the chart-ready trends endpoint with range and
try {
await apiClient.getTrendsDashboard('90d', 'month');
assert.equal(seenUrl, `${BASE_URL}/api/stats/trends/dashboard?range=90d&groupBy=month`);
assert.equal(
seenUrl,
`${BASE_URL}/api/stats/trends/dashboard?range=90d&groupBy=month&fillEmpty=true`,
);
} finally {
globalThis.fetch = originalFetch;
}
@@ -208,8 +211,11 @@ test('getTrendsDashboard accepts 365d range and builds correct URL', async () =>
}) as typeof globalThis.fetch;
try {
await apiClient.getTrendsDashboard('365d', 'day');
assert.equal(seenUrl, `${BASE_URL}/api/stats/trends/dashboard?range=365d&groupBy=day`);
await apiClient.getTrendsDashboard('365d', 'day', false);
assert.equal(
seenUrl,
`${BASE_URL}/api/stats/trends/dashboard?range=365d&groupBy=day&fillEmpty=false`,
);
} finally {
globalThis.fetch = originalFetch;
}
+6 -2
View File
@@ -162,9 +162,13 @@ export const apiClient = {
fetchJson<NewAnimePerDay[]>(`/api/stats/trends/new-anime-per-day?limit=${limit}`),
getWatchTimePerAnime: (limit = 90) =>
fetchJson<WatchTimePerAnime[]>(`/api/stats/trends/watch-time-per-anime?limit=${limit}`),
getTrendsDashboard: (range: '7d' | '30d' | '90d' | '365d' | 'all', groupBy: 'day' | 'month') =>
getTrendsDashboard: (
range: '7d' | '30d' | '90d' | '365d' | 'all',
groupBy: 'day' | 'month',
fillEmpty = true,
) =>
fetchJson<TrendsDashboardData>(
`/api/stats/trends/dashboard?range=${encodeURIComponent(range)}&groupBy=${encodeURIComponent(groupBy)}`,
`/api/stats/trends/dashboard?range=${encodeURIComponent(range)}&groupBy=${encodeURIComponent(groupBy)}&fillEmpty=${fillEmpty ? 'true' : 'false'}`,
),
getWordDetail: (wordId: number) =>
fetchJson<WordDetailData>(`/api/stats/vocabulary/${wordId}/detail`),