perf(stats): speed up dashboard queries on large databases (#322)

This commit is contained in:
sudacode authored and GitHub committed 2026-11-03 22:04:01 -08:00
1 parent 8976d78390
commit d8c2315405
18 files changed
+701 -318

No files matched your search

+15 -19
View File
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useMemo, useState } from 'react';
import { useTrends, type TimeRange, type GroupBy } from '../../hooks/useTrends';
import { DateRangeSelector } from './DateRangeSelector';
import { TrendChart } from './TrendChart';
@@ -20,6 +20,7 @@ import {
type MaxTitlesMode,
} from './anime-visibility';
import { LibrarySummarySection } from './LibrarySummarySection';
import { buildAnimeCumulative } from './anime-cumulative';
const MAX_TITLES_MODE_LABELS: Record<MaxTitlesMode, string> = {
recent: 'most recent',
@@ -156,6 +157,10 @@ export function TrendsTab() {
const [maxTitles, setMaxTitles] = useState<number | null>(() => loadMaxTitles());
const [maxTitlesMode, setMaxTitlesMode] = useState<MaxTitlesMode>(() => loadMaxTitlesMode());
const { data, loading, error } = useTrends(range, groupBy, showEmptyDays);
const animeCumulative = useMemo(
() => (data ? buildAnimeCumulative(data.animeDaily) : null),
[data],
);
const updateHiddenAnime = (next: Set<string>) => {
setHiddenAnime(next);
@@ -187,30 +192,21 @@ export function TrendsTab() {
if (loading) return <div className="text-ctp-overlay2 p-4">Loading...</div>;
if (error) return <div className="text-ctp-red p-4">Error: {error}</div>;
if (!data) return null;
if (!data || !animeCumulative) return null;
const animeTitles = buildAnimeVisibilityOptions([
data.animeCumulative.episodes,
data.animeCumulative.cards,
data.animeCumulative.words,
data.animeCumulative.watchTime,
animeCumulative.episodes,
animeCumulative.cards,
animeCumulative.words,
animeCumulative.watchTime,
]);
const activeHiddenAnime = pruneHiddenAnime(hiddenAnime, animeTitles);
const filteredAnimeProgress = filterHiddenAnimeData(
data.animeCumulative.episodes,
activeHiddenAnime,
);
const filteredCardsProgress = filterHiddenAnimeData(
data.animeCumulative.cards,
activeHiddenAnime,
);
const filteredWordsProgress = filterHiddenAnimeData(
data.animeCumulative.words,
activeHiddenAnime,
);
const filteredAnimeProgress = filterHiddenAnimeData(animeCumulative.episodes, activeHiddenAnime);
const filteredCardsProgress = filterHiddenAnimeData(animeCumulative.cards, activeHiddenAnime);
const filteredWordsProgress = filterHiddenAnimeData(animeCumulative.words, activeHiddenAnime);
const filteredWatchTimeProgress = filterHiddenAnimeData(
data.animeCumulative.watchTime,
animeCumulative.watchTime,
activeHiddenAnime,
);
@@ -0,0 +1,36 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { buildCumulativePerAnime } from './anime-cumulative';
test('buildCumulativePerAnime carries each title forward to the last active day', () => {
const result = buildCumulativePerAnime([
{ epochDay: 103, animeTitle: 'B', value: 4 },
{ epochDay: 100, animeTitle: 'A', value: 10 },
{ epochDay: 102, animeTitle: 'A', value: 5 },
{ epochDay: 102, animeTitle: 'A', value: 1 },
]);
assert.deepEqual(
result
.filter((point) => point.animeTitle === 'A')
.map((point) => [point.epochDay, point.value]),
[
[100, 10],
[101, 10],
[102, 16],
[103, 16],
],
);
// A title starts at its own first active day, not the earliest day overall.
assert.deepEqual(
result
.filter((point) => point.animeTitle === 'B')
.map((point) => [point.epochDay, point.value]),
[[103, 4]],
);
});
test('buildCumulativePerAnime returns nothing for no points', () => {
assert.deepEqual(buildCumulativePerAnime([]), []);
});
@@ -0,0 +1,41 @@
import type { TrendsDashboardData } from '../../types/stats';
import type { PerAnimeDataPoint } from './StackedTrendChart';
type AnimeSeries = TrendsDashboardData['animeDaily'];
/**
* Turns sparse per-title daily values into running totals: one point per
* title for every calendar day from its first active day to the last active
* day of any title, so the stacked area charts carry each total forward.
*/
export function buildCumulativePerAnime(points: PerAnimeDataPoint[]): PerAnimeDataPoint[] {
const byAnime = new Map<string, Map<number, number>>();
let maxDay = Number.NEGATIVE_INFINITY;
for (const point of points) {
const dayMap = byAnime.get(point.animeTitle) ?? new Map<number, number>();
dayMap.set(point.epochDay, (dayMap.get(point.epochDay) ?? 0) + point.value);
byAnime.set(point.animeTitle, dayMap);
maxDay = Math.max(maxDay, point.epochDay);
}
const result: PerAnimeDataPoint[] = [];
for (const [animeTitle, dayMap] of byAnime) {
const firstDay = Math.min(...dayMap.keys());
let cumulative = 0;
for (let epochDay = firstDay; epochDay <= maxDay; epochDay += 1) {
cumulative += dayMap.get(epochDay) ?? 0;
result.push({ epochDay, animeTitle, value: cumulative });
}
}
return result;
}
export function buildAnimeCumulative(daily: AnimeSeries): AnimeSeries {
return {
watchTime: buildCumulativePerAnime(daily.watchTime),
episodes: buildCumulativePerAnime(daily.episodes),
cards: buildCumulativePerAnime(daily.cards),
words: buildCumulativePerAnime(daily.words),
};
}