mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-07-07 13:08:54 -07:00
feat(stats): Trends dashboard overhaul — title visibility, ranking modes, calendar-accurate windows, tooltips (#140)
This commit is contained in:
@@ -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']);
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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`),
|
||||
|
||||
Reference in New Issue
Block a user