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
+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} />