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:
@@ -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} />
|
||||
|
||||
Reference in New Issue
Block a user