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
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: stats
- Show all trend chart titles by default, persist hidden-title choices, and add a per-chart top-title limit selector.
@@ -729,7 +729,7 @@ describe('stats server API routes', () => {
const res = await app.request('/api/stats/trends/dashboard?range=90d&groupBy=month'); const res = await app.request('/api/stats/trends/dashboard?range=90d&groupBy=month');
assert.equal(res.status, 200); assert.equal(res.status, 200);
const body = await res.json(); const body = await res.json();
assert.deepEqual(seenArgs, ['90d', 'month']); assert.deepEqual(seenArgs, ['90d', 'month', true]);
assert.deepEqual(body.activity.watchTime, TRENDS_DASHBOARD.activity.watchTime); assert.deepEqual(body.activity.watchTime, TRENDS_DASHBOARD.activity.watchTime);
assert.deepEqual(body.librarySummary, TRENDS_DASHBOARD.librarySummary); assert.deepEqual(body.librarySummary, TRENDS_DASHBOARD.librarySummary);
}); });
@@ -747,7 +747,7 @@ describe('stats server API routes', () => {
const res = await app.request('/api/stats/trends/dashboard?range=365d&groupBy=month'); const res = await app.request('/api/stats/trends/dashboard?range=365d&groupBy=month');
assert.equal(res.status, 200); assert.equal(res.status, 200);
assert.deepEqual(seenArgs, ['365d', 'month']); assert.deepEqual(seenArgs, ['365d', 'month', true]);
}); });
it('GET /api/stats/trends/dashboard falls back to safe defaults for invalid params', async () => { it('GET /api/stats/trends/dashboard falls back to safe defaults for invalid params', async () => {
@@ -763,7 +763,25 @@ describe('stats server API routes', () => {
const res = await app.request('/api/stats/trends/dashboard?range=weird&groupBy=year'); const res = await app.request('/api/stats/trends/dashboard?range=weird&groupBy=year');
assert.equal(res.status, 200); assert.equal(res.status, 200);
assert.deepEqual(seenArgs, ['30d', 'day']); assert.deepEqual(seenArgs, ['30d', 'day', true]);
});
it('GET /api/stats/trends/dashboard forwards fillEmpty=false to disable zero-fill', async () => {
let seenArgs: unknown[] = [];
const app = createStatsApp(
createMockTracker({
getTrendsDashboard: async (...args: unknown[]) => {
seenArgs = args;
return TRENDS_DASHBOARD;
},
}),
);
const res = await app.request(
'/api/stats/trends/dashboard?range=30d&groupBy=day&fillEmpty=false',
);
assert.equal(res.status, 200);
assert.deepEqual(seenArgs, ['30d', 'day', false]);
}); });
it('GET /api/stats/vocabulary/occurrences returns recent occurrence rows for a word', async () => { it('GET /api/stats/vocabulary/occurrences returns recent occurrence rows for a word', async () => {
@@ -571,8 +571,9 @@ export class ImmersionTrackerService {
async getTrendsDashboard( async getTrendsDashboard(
range: '7d' | '30d' | '90d' | '365d' | 'all' = '30d', range: '7d' | '30d' | '90d' | '365d' | 'all' = '30d',
groupBy: 'day' | 'month' = 'day', groupBy: 'day' | 'month' = 'day',
fillEmptyBuckets = true,
): Promise<unknown> { ): Promise<unknown> {
return getTrendsDashboard(this.db, range, groupBy); return getTrendsDashboard(this.db, range, groupBy, fillEmptyBuckets);
} }
async getVocabularyStats(limit = 100, excludePos?: string[]): Promise<VocabularyStatsRow[]> { async getVocabularyStats(limit = 100, excludePos?: string[]): Promise<VocabularyStatsRow[]> {
@@ -1003,6 +1003,121 @@ test('getTrendsDashboard keeps local-midnight session buckets separate', () => {
} }
}); });
test('getTrendsDashboard 30d day range zero-fills empty calendar days', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
withMockNowMs('1772395200000', () => {
try {
ensureSchema(db);
const videoId = getOrCreateVideoRecord(db, 'local:/tmp/30d-zerofill.mkv', {
canonicalTitle: '30d Zero Fill',
sourcePath: '/tmp/30d-zerofill.mkv',
sourceUrl: null,
sourceType: SOURCE_TYPE_LOCAL,
});
const insertDailyRollup = db.prepare(
`
INSERT INTO imm_daily_rollups (
rollup_day, video_id, total_sessions, total_active_min, total_lines_seen,
total_tokens_seen, total_cards, CREATED_DATE, LAST_UPDATE_DATE
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
);
const createdAtMs = '1772395200000';
// Local "today" for the mocked clock is epoch day 20513. Seed only two
// active days inside the 30-day window, leaving the rest empty.
const todayEpochDay = 20513;
insertDailyRollup.run(todayEpochDay, videoId, 1, 30, 4, 100, 2, createdAtMs, createdAtMs);
insertDailyRollup.run(
todayEpochDay - 10,
videoId,
1,
45,
4,
120,
3,
createdAtMs,
createdAtMs,
);
const dashboard = getTrendsDashboard(db, '30d', 'day');
// Exactly 30 calendar days, not just the two active ones.
assert.equal(dashboard.activity.watchTime.length, 30);
// Most recent day carries its seeded value; a gap day reads zero.
assert.equal(dashboard.activity.watchTime.at(-1)?.value, 30);
assert.equal(dashboard.activity.watchTime.at(-2)?.value, 0);
// Only the two seeded days contribute to the totals.
const nonZeroDays = dashboard.activity.watchTime.filter((point) => point.value > 0);
assert.equal(nonZeroDays.length, 2);
// Cumulative watch time still tops out at the sum of both active days.
assert.equal(dashboard.progress.watchTime.at(-1)?.value, 75);
// Every day-bucketed series shares the same 30-day axis.
assert.equal(dashboard.progress.episodes.length, 30);
assert.deepEqual(
dashboard.progress.episodes.map((point) => point.label),
dashboard.activity.watchTime.map((point) => point.label),
);
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
});
test('getTrendsDashboard skips empty calendar days when zero-fill is disabled', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
withMockNowMs('1772395200000', () => {
try {
ensureSchema(db);
const videoId = getOrCreateVideoRecord(db, 'local:/tmp/no-zerofill.mkv', {
canonicalTitle: 'No Zero Fill',
sourcePath: '/tmp/no-zerofill.mkv',
sourceUrl: null,
sourceType: SOURCE_TYPE_LOCAL,
});
const insertDailyRollup = db.prepare(
`
INSERT INTO imm_daily_rollups (
rollup_day, video_id, total_sessions, total_active_min, total_lines_seen,
total_tokens_seen, total_cards, CREATED_DATE, LAST_UPDATE_DATE
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
);
const createdAtMs = '1772395200000';
const todayEpochDay = 20513;
insertDailyRollup.run(todayEpochDay, videoId, 1, 30, 4, 100, 2, createdAtMs, createdAtMs);
insertDailyRollup.run(
todayEpochDay - 10,
videoId,
1,
45,
4,
120,
3,
createdAtMs,
createdAtMs,
);
const filled = getTrendsDashboard(db, '30d', 'day', true);
assert.equal(filled.activity.watchTime.length, 30);
const compact = getTrendsDashboard(db, '30d', 'day', false);
// Only the two active days survive; no zero-filled gaps.
assert.equal(compact.activity.watchTime.length, 2);
assert.ok(compact.activity.watchTime.every((point) => point.value > 0));
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
});
test( test(
'getTrendsDashboard supports 365d range and caps day buckets at 365', 'getTrendsDashboard supports 365d range and caps day buckets at 365',
{ timeout: 20_000 }, { timeout: 20_000 },
@@ -1266,7 +1381,10 @@ test('getTrendsDashboard month grouping spans every touched calendar month and k
const dashboard = getTrendsDashboard(db, '30d', 'month'); const dashboard = getTrendsDashboard(db, '30d', 'month');
assert.equal(dashboard.activity.watchTime.length, 2); // The 30d window (mocked now Mar 1 → cutoff Jan 31) spans three calendar
// months, so January is zero-filled rather than dropped.
assert.equal(dashboard.activity.watchTime.length, 3);
assert.equal(dashboard.activity.watchTime[0]?.value, 0);
assert.deepEqual( assert.deepEqual(
dashboard.progress.newWords.map((point) => point.label), dashboard.progress.newWords.map((point) => point.label),
dashboard.activity.watchTime.map((point) => point.label), dashboard.activity.watchTime.map((point) => point.label),
@@ -205,6 +205,59 @@ function resolveTrendAnimeTitle(value: {
return sanitizeTrendTitle(value.animeTitle ?? value.canonicalTitle ?? 'Unknown'); return sanitizeTrendTitle(value.animeTitle ?? value.canonicalTitle ?? 'Unknown');
} }
// Ordered list of bucket keys (epoch days or YYYYMM months) covering the
// selected range, so charts render one point per calendar bucket instead of
// silently collapsing to only the buckets that have activity. Returns null for
// the unbounded "all" range, where builders fall back to the buckets in data.
function buildBucketAxis(
db: DatabaseSync,
groupBy: TrendGroupBy,
cutoffMs: string | null,
referenceMs: string,
): number[] | null {
if (cutoffMs === null) {
return null;
}
if (groupBy === 'month') {
const startKey = getLocalMonthKey(db, cutoffMs);
const endKey = getLocalMonthKey(db, referenceMs);
const keys: number[] = [];
let year = Math.floor(startKey / 100);
let month = startKey % 100;
const endYear = Math.floor(endKey / 100);
const endMonth = endKey % 100;
while (year < endYear || (year === endYear && month <= endMonth)) {
keys.push(year * 100 + month);
month += 1;
if (month > 12) {
month = 1;
year += 1;
}
}
return keys;
}
const startDay = getLocalEpochDay(db, cutoffMs);
const endDay = getLocalEpochDay(db, referenceMs);
const keys: number[] = [];
for (let day = startDay; day <= endDay; day += 1) {
keys.push(day);
}
return keys;
}
// Project a bucket→value map onto the axis, filling absent buckets with zero.
// Without an axis (the "all" range) it falls back to the populated buckets in
// ascending order, preserving the previous behaviour.
function fillAxisPoints(
axis: number[] | null,
valueByBucket: Map<number, number>,
): TrendChartPoint[] {
const keys = axis ?? [...valueByBucket.keys()].sort((left, right) => left - right);
return keys.map((key) => ({ label: makeTrendLabel(key), value: valueByBucket.get(key) ?? 0 }));
}
function accumulatePoints(points: TrendChartPoint[]): TrendChartPoint[] { function accumulatePoints(points: TrendChartPoint[]): TrendChartPoint[] {
let sum = 0; let sum = 0;
return points.map((point) => { return points.map((point) => {
@@ -216,7 +269,7 @@ function accumulatePoints(points: TrendChartPoint[]): TrendChartPoint[] {
}); });
} }
function buildAggregatedTrendRows(rollups: ImmersionSessionRollupRow[]) { function buildAggregatedTrendRows(rollups: ImmersionSessionRollupRow[], axis: number[] | null) {
const byKey = new Map< const byKey = new Map<
number, number,
{ activeMin: number; cards: number; words: number; sessions: number } { activeMin: number; cards: number; words: number; sessions: number }
@@ -236,15 +289,17 @@ function buildAggregatedTrendRows(rollups: ImmersionSessionRollupRow[]) {
byKey.set(rollup.rollupDayOrMonth, existing); byKey.set(rollup.rollupDayOrMonth, existing);
} }
return Array.from(byKey.entries()) const keys = axis ?? Array.from(byKey.keys()).sort((left, right) => left - right);
.sort(([left], [right]) => left - right) return keys.map((key) => {
.map(([key, value]) => ({ const value = byKey.get(key) ?? { activeMin: 0, cards: 0, words: 0, sessions: 0 };
return {
label: makeTrendLabel(key), label: makeTrendLabel(key),
activeMin: Math.round(value.activeMin), activeMin: Math.round(value.activeMin),
cards: value.cards, cards: value.cards,
words: value.words, words: value.words,
sessions: value.sessions, sessions: value.sessions,
})); };
});
} }
function buildEfficiencyRates(rows: ReturnType<typeof buildAggregatedTrendRows>): { function buildEfficiencyRates(rows: ReturnType<typeof buildAggregatedTrendRows>): {
@@ -289,40 +344,24 @@ function buildWatchTimeByHour(sessions: TrendSessionMetricRow[]): TrendChartPoin
})); }));
} }
function dayLabel(epochDay: number): string { function buildSessionSeries(
const { month, day } = dayPartsFromEpochDay(epochDay);
return `${MONTH_NAMES[month - 1]} ${day}`;
}
function buildSessionSeriesByDay(
sessions: TrendSessionMetricRow[], sessions: TrendSessionMetricRow[],
groupBy: TrendGroupBy,
getValue: (session: TrendSessionMetricRow) => number, getValue: (session: TrendSessionMetricRow) => number,
axis: number[] | null,
): TrendChartPoint[] { ): TrendChartPoint[] {
const byDay = new Map<number, number>(); const byBucket = new Map<number, number>();
for (const session of sessions) { for (const session of sessions) {
byDay.set(session.epochDay, (byDay.get(session.epochDay) ?? 0) + getValue(session)); const bucketKey = groupBy === 'month' ? session.monthKey : session.epochDay;
byBucket.set(bucketKey, (byBucket.get(bucketKey) ?? 0) + getValue(session));
} }
return Array.from(byDay.entries()) return fillAxisPoints(axis, byBucket);
.sort(([left], [right]) => left - right)
.map(([epochDay, value]) => ({ label: dayLabel(epochDay), value }));
}
function buildSessionSeriesByMonth(
sessions: TrendSessionMetricRow[],
getValue: (session: TrendSessionMetricRow) => number,
): TrendChartPoint[] {
const byMonth = new Map<number, number>();
for (const session of sessions) {
byMonth.set(session.monthKey, (byMonth.get(session.monthKey) ?? 0) + getValue(session));
}
return Array.from(byMonth.entries())
.sort(([left], [right]) => left - right)
.map(([monthKey, value]) => ({ label: makeTrendLabel(monthKey), value }));
} }
function buildLookupsPerHundredWords( function buildLookupsPerHundredWords(
sessions: TrendSessionMetricRow[], sessions: TrendSessionMetricRow[],
groupBy: TrendGroupBy, groupBy: TrendGroupBy,
axis: number[] | null,
): TrendChartPoint[] { ): TrendChartPoint[] {
const lookupsByBucket = new Map<number, number>(); const lookupsByBucket = new Map<number, number>();
const wordsByBucket = new Map<number, number>(); const wordsByBucket = new Map<number, number>();
@@ -339,15 +378,12 @@ function buildLookupsPerHundredWords(
); );
} }
return Array.from(lookupsByBucket.entries()) const ratioByBucket = new Map<number, number>();
.sort(([left], [right]) => left - right) for (const [bucketKey, lookups] of lookupsByBucket) {
.map(([bucketKey, lookups]) => { const words = wordsByBucket.get(bucketKey) ?? 0;
const words = wordsByBucket.get(bucketKey) ?? 0; ratioByBucket.set(bucketKey, words > 0 ? +((lookups / words) * 100).toFixed(1) : 0);
return { }
label: groupBy === 'month' ? makeTrendLabel(bucketKey) : dayLabel(bucketKey), return fillAxisPoints(axis, ratioByBucket);
value: words > 0 ? +((lookups / words) * 100).toFixed(1) : 0,
};
});
} }
function buildCumulativePerAnime(points: TrendPerAnimePoint[]): TrendPerAnimePoint[] { function buildCumulativePerAnime(points: TrendPerAnimePoint[]): TrendPerAnimePoint[] {
@@ -557,46 +593,26 @@ function buildEpisodesPerAnimeFromDailyRollups(
return result; return result;
} }
function buildEpisodesPerDayFromDailyRollups( function buildEpisodesSeriesFromRollups(
rollups: ImmersionSessionRollupRow[], rollups: ImmersionSessionRollupRow[],
axis: number[] | null,
): TrendChartPoint[] { ): TrendChartPoint[] {
const byDay = new Map<number, Set<number>>(); const byBucket = new Map<number, Set<number>>();
for (const rollup of rollups) { for (const rollup of rollups) {
if (rollup.videoId === null) { if (rollup.videoId === null) {
continue; continue;
} }
const videoIds = byDay.get(rollup.rollupDayOrMonth) ?? new Set<number>(); const videoIds = byBucket.get(rollup.rollupDayOrMonth) ?? new Set<number>();
videoIds.add(rollup.videoId); videoIds.add(rollup.videoId);
byDay.set(rollup.rollupDayOrMonth, videoIds); byBucket.set(rollup.rollupDayOrMonth, videoIds);
} }
return Array.from(byDay.entries()) const counts = new Map<number, number>();
.sort(([left], [right]) => left - right) for (const [bucketKey, videoIds] of byBucket) {
.map(([epochDay, videoIds]) => ({ counts.set(bucketKey, videoIds.size);
label: dayLabel(epochDay),
value: videoIds.size,
}));
}
function buildEpisodesPerMonthFromRollups(rollups: ImmersionSessionRollupRow[]): TrendChartPoint[] {
const byMonth = new Map<number, Set<number>>();
for (const rollup of rollups) {
if (rollup.videoId === null) {
continue;
}
const videoIds = byMonth.get(rollup.rollupDayOrMonth) ?? new Set<number>();
videoIds.add(rollup.videoId);
byMonth.set(rollup.rollupDayOrMonth, videoIds);
} }
return fillAxisPoints(axis, counts);
return Array.from(byMonth.entries())
.sort(([left], [right]) => left - right)
.map(([monthKey, videoIds]) => ({
label: makeTrendLabel(monthKey),
value: videoIds.size,
}));
} }
function getTrendSessionMetrics( function getTrendSessionMetrics(
@@ -639,7 +655,11 @@ function getTrendSessionMetrics(
})); }));
} }
function buildNewWordsPerDay(db: DatabaseSync, cutoffMs: string | null): TrendChartPoint[] { function buildNewWordsPerDay(
db: DatabaseSync,
cutoffMs: string | null,
axis: number[] | null,
): TrendChartPoint[] {
const whereClause = cutoffMs === null ? '' : 'AND first_seen >= ?'; const whereClause = cutoffMs === null ? '' : 'AND first_seen >= ?';
const prepared = db.prepare(` const prepared = db.prepare(`
SELECT SELECT
@@ -662,13 +682,15 @@ function buildNewWordsPerDay(db: DatabaseSync, cutoffMs: string | null): TrendCh
wordCount: number; wordCount: number;
}>; }>;
return rows.map((row) => ({ const byBucket = new Map<number, number>(rows.map((row) => [row.epochDay, row.wordCount]));
label: dayLabel(row.epochDay), return fillAxisPoints(axis, byBucket);
value: row.wordCount,
}));
} }
function buildNewWordsPerMonth(db: DatabaseSync, cutoffMs: string | null): TrendChartPoint[] { function buildNewWordsPerMonth(
db: DatabaseSync,
cutoffMs: string | null,
axis: number[] | null,
): TrendChartPoint[] {
const whereClause = cutoffMs === null ? '' : 'AND first_seen >= ?'; const whereClause = cutoffMs === null ? '' : 'AND first_seen >= ?';
const prepared = db.prepare(` const prepared = db.prepare(`
SELECT SELECT
@@ -691,16 +713,15 @@ function buildNewWordsPerMonth(db: DatabaseSync, cutoffMs: string | null): Trend
wordCount: number; wordCount: number;
}>; }>;
return rows.map((row) => ({ const byBucket = new Map<number, number>(rows.map((row) => [row.monthKey, row.wordCount]));
label: makeTrendLabel(row.monthKey), return fillAxisPoints(axis, byBucket);
value: row.wordCount,
}));
} }
export function getTrendsDashboard( export function getTrendsDashboard(
db: DatabaseSync, db: DatabaseSync,
range: TrendRange = '30d', range: TrendRange = '30d',
groupBy: TrendGroupBy = 'day', groupBy: TrendGroupBy = 'day',
fillEmptyBuckets = true,
): TrendsDashboardQueryResult { ): TrendsDashboardQueryResult {
const dayLimit = getTrendDayLimit(range); const dayLimit = getTrendDayLimit(range);
const monthlyLimit = getTrendMonthlyLimit(db, range); const monthlyLimit = getTrendMonthlyLimit(db, range);
@@ -708,6 +729,11 @@ export function getTrendsDashboard(
const useMonthlyBuckets = groupBy === 'month'; const useMonthlyBuckets = groupBy === 'month';
const dailyRollups = getDailyRollups(db, dayLimit); const dailyRollups = getDailyRollups(db, dayLimit);
const monthlyRollups = getMonthlyRollups(db, monthlyLimit); const monthlyRollups = getMonthlyRollups(db, monthlyLimit);
// A null axis makes the builders fall back to only the buckets present in the
// data; the contiguous axis zero-fills every calendar bucket in the window.
const bucketAxis = fillEmptyBuckets
? buildBucketAxis(db, groupBy, cutoffMs, currentDbTimestamp())
: null;
const chartRollups = useMonthlyBuckets ? monthlyRollups : dailyRollups; const chartRollups = useMonthlyBuckets ? monthlyRollups : dailyRollups;
const sessions = getTrendSessionMetrics(db, cutoffMs); const sessions = getTrendSessionMetrics(db, cutoffMs);
@@ -716,7 +742,7 @@ export function getTrendsDashboard(
dailyRollups.map((rollup) => rollup.videoId), dailyRollups.map((rollup) => rollup.videoId),
); );
const aggregatedRows = buildAggregatedTrendRows(chartRollups); const aggregatedRows = buildAggregatedTrendRows(chartRollups, bucketAxis);
const efficiency = buildEfficiencyRates(aggregatedRows); const efficiency = buildEfficiencyRates(aggregatedRows);
const activity = { const activity = {
watchTime: aggregatedRows.map((row) => ({ label: row.label, value: row.activeMin })), watchTime: aggregatedRows.map((row) => ({ label: row.label, value: row.activeMin })),
@@ -751,22 +777,23 @@ export function getTrendsDashboard(
sessions: accumulatePoints(activity.sessions), sessions: accumulatePoints(activity.sessions),
words: accumulatePoints(activity.words), words: accumulatePoints(activity.words),
newWords: accumulatePoints( newWords: accumulatePoints(
useMonthlyBuckets ? buildNewWordsPerMonth(db, cutoffMs) : buildNewWordsPerDay(db, cutoffMs), useMonthlyBuckets
? buildNewWordsPerMonth(db, cutoffMs, bucketAxis)
: buildNewWordsPerDay(db, cutoffMs, bucketAxis),
), ),
cards: accumulatePoints(activity.cards), cards: accumulatePoints(activity.cards),
episodes: accumulatePoints( episodes: accumulatePoints(
useMonthlyBuckets buildEpisodesSeriesFromRollups(
? buildEpisodesPerMonthFromRollups(monthlyRollups) useMonthlyBuckets ? monthlyRollups : dailyRollups,
: buildEpisodesPerDayFromDailyRollups(dailyRollups), bucketAxis,
),
), ),
lookups: accumulatePoints( lookups: accumulatePoints(
useMonthlyBuckets buildSessionSeries(sessions, groupBy, (session) => session.yomitanLookupCount, bucketAxis),
? buildSessionSeriesByMonth(sessions, (session) => session.yomitanLookupCount)
: buildSessionSeriesByDay(sessions, (session) => session.yomitanLookupCount),
), ),
}, },
ratios: { ratios: {
lookupsPerHundred: buildLookupsPerHundredWords(sessions, groupBy), lookupsPerHundred: buildLookupsPerHundredWords(sessions, groupBy, bucketAxis),
cardsPerHour: efficiency.cardsPerHour, cardsPerHour: efficiency.cardsPerHour,
readingSpeed: efficiency.readingSpeed, readingSpeed: efficiency.readingSpeed,
}, },
+8 -1
View File
@@ -81,6 +81,12 @@ function parseTrendGroupBy(raw: string | undefined): 'day' | 'month' {
return raw === 'month' ? 'month' : 'day'; return raw === 'month' ? 'month' : 'day';
} }
// Defaults to true (zero-fill empty calendar buckets); only an explicit
// "false" opts into the compact, data-only view.
function parseTrendFillEmpty(raw: string | undefined): boolean {
return raw !== 'false';
}
function parseEventTypesQuery(raw: string | undefined): number[] | undefined { function parseEventTypesQuery(raw: string | undefined): number[] | undefined {
if (!raw) return undefined; if (!raw) return undefined;
const parsed = raw const parsed = raw
@@ -685,7 +691,8 @@ export function createStatsApp(
app.get('/api/stats/trends/dashboard', async (c) => { app.get('/api/stats/trends/dashboard', async (c) => {
const range = parseTrendRange(c.req.query('range')); const range = parseTrendRange(c.req.query('range'));
const groupBy = parseTrendGroupBy(c.req.query('groupBy')); const groupBy = parseTrendGroupBy(c.req.query('groupBy'));
return c.json(await tracker.getTrendsDashboard(range, groupBy)); const fillEmpty = parseTrendFillEmpty(c.req.query('fillEmpty'));
return c.json(await tracker.getTrendsDashboard(range, groupBy, fillEmpty));
}); });
app.get('/api/stats/sessions', async (c) => { app.get('/api/stats/sessions', async (c) => {
@@ -3,8 +3,10 @@ import type { TimeRange, GroupBy } from '../../hooks/useTrends';
interface DateRangeSelectorProps { interface DateRangeSelectorProps {
range: TimeRange; range: TimeRange;
groupBy: GroupBy; groupBy: GroupBy;
fillEmpty: boolean;
onRangeChange: (r: TimeRange) => void; onRangeChange: (r: TimeRange) => void;
onGroupByChange: (g: GroupBy) => void; onGroupByChange: (g: GroupBy) => void;
onFillEmptyChange: (fillEmpty: boolean) => void;
} }
function SegmentedControl<T extends string>({ function SegmentedControl<T extends string>({
@@ -46,11 +48,13 @@ function SegmentedControl<T extends string>({
export function DateRangeSelector({ export function DateRangeSelector({
range, range,
groupBy, groupBy,
fillEmpty,
onRangeChange, onRangeChange,
onGroupByChange, onGroupByChange,
onFillEmptyChange,
}: DateRangeSelectorProps) { }: DateRangeSelectorProps) {
return ( return (
<div className="flex items-center gap-4 text-sm"> <div className="flex flex-wrap items-center gap-4 text-sm">
<SegmentedControl <SegmentedControl
label="Range" label="Range"
options={['7d', '30d', '90d', '365d', 'all'] as TimeRange[]} options={['7d', '30d', '90d', '365d', 'all'] as TimeRange[]}
@@ -65,6 +69,13 @@ export function DateRangeSelector({
onChange={onGroupByChange} onChange={onGroupByChange}
formatLabel={(g) => g.charAt(0).toUpperCase() + g.slice(1)} 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> </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; title: string;
data: PerAnimeDataPoint[]; data: PerAnimeDataPoint[];
colorPalette?: string[]; colorPalette?: string[];
maxSeries?: number | null;
maxSeriesMode?: SeriesRankMode;
} }
const DEFAULT_LINE_COLORS = [ const DEFAULT_LINE_COLORS = [
@@ -33,19 +35,147 @@ const DEFAULT_LINE_COLORS = [
'#f4dbd6', '#f4dbd6',
]; ];
function buildLineData(raw: PerAnimeDataPoint[]) { // Wrap the tooltip into extra columns once a single column would get too tall,
const totalByAnime = new Map<string, number>(); // 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) { 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 stats = new Map<string, { total: number; recentDay: number }>();
const topTitles = sorted.slice(0, 7).map(([title]) => title); for (const [title, points] of pointsByTitle) {
const topSet = new Set(topTitles); 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>>(); const byDay = new Map<number, Record<string, number>>();
for (const entry of raw) { for (const entry of raw) {
if (!topSet.has(entry.animeTitle)) continue; if (!seriesSet.has(entry.animeTitle)) continue;
const row = byDay.get(entry.epochDay) ?? {}; const row = byDay.get(entry.epochDay) ?? {};
row[entry.animeTitle] = (row[entry.animeTitle] ?? 0) + Math.round(entry.value * 10) / 10; row[entry.animeTitle] = (row[entry.animeTitle] ?? 0) + Math.round(entry.value * 10) / 10;
byDay.set(entry.epochDay, row); byDay.set(entry.epochDay, row);
@@ -60,17 +190,23 @@ function buildLineData(raw: PerAnimeDataPoint[]) {
day: 'numeric', day: 'numeric',
}), }),
}; };
for (const title of topTitles) { for (const title of seriesKeys) {
row[title] = values[title] ?? 0; row[title] = values[title] ?? 0;
} }
return row; return row;
}); });
return { points, seriesKeys: topTitles }; return { points, seriesKeys };
} }
export function StackedTrendChart({ title, data, colorPalette }: StackedTrendChartProps) { export function StackedTrendChart({
const { points, seriesKeys } = buildLineData(data); title,
data,
colorPalette,
maxSeries,
maxSeriesMode,
}: StackedTrendChartProps) {
const { points, seriesKeys } = buildLineData(data, maxSeries, maxSeriesMode);
const colors = colorPalette ?? DEFAULT_LINE_COLORS; const colors = colorPalette ?? DEFAULT_LINE_COLORS;
if (points.length === 0) { if (points.length === 0) {
@@ -100,7 +236,11 @@ export function StackedTrendChart({ title, data, colorPalette }: StackedTrendCha
tickLine={false} tickLine={false}
width={32} width={32}
/> />
<Tooltip contentStyle={TOOLTIP_CONTENT_STYLE} /> <Tooltip
content={<StackedTooltip />}
wrapperStyle={{ zIndex: 50 }}
allowEscapeViewBox={{ x: false, y: true }}
/>
{seriesKeys.map((key, i) => ( {seriesKeys.map((key, i) => (
<Area <Area
key={key} key={key}
@@ -115,18 +255,18 @@ export function StackedTrendChart({ title, data, colorPalette }: StackedTrendCha
))} ))}
</AreaChart> </AreaChart>
</ResponsiveContainer> </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) => ( {seriesKeys.map((key, i) => (
<span <span
key={key} 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} title={key}
> >
<span <span
className="inline-block w-2 h-2 rounded-full shrink-0" className="inline-block w-2 h-2 rounded-full shrink-0"
style={{ backgroundColor: colors[i % colors.length] }} style={{ backgroundColor: colors[i % colors.length] }}
/> />
<span className="truncate">{key}</span> <span>{key}</span>
</span> </span>
))} ))}
</div> </div>
@@ -8,9 +8,13 @@ test('AnimeVisibilityFilter uses title visibility wording', () => {
<AnimeVisibilityFilter <AnimeVisibilityFilter
animeTitles={['KonoSuba']} animeTitles={['KonoSuba']}
hiddenAnime={new Set()} hiddenAnime={new Set()}
maxTitles={null}
maxTitlesMode="total"
onShowAll={() => {}} onShowAll={() => {}}
onHideAll={() => {}} onHideAll={() => {}}
onToggleAnime={() => {}} onToggleAnime={() => {}}
onMaxTitlesChange={() => {}}
onMaxTitlesModeChange={() => {}}
/>, />,
); );
@@ -18,6 +22,64 @@ test('AnimeVisibilityFilter uses title visibility wording', () => {
assert.doesNotMatch(markup, /Anime Visibility/); 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 () => { 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(); 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 { TrendChart } from './TrendChart';
import { StackedTrendChart } from './StackedTrendChart'; import { StackedTrendChart } from './StackedTrendChart';
import { import {
MAX_TITLES_MODES,
MAX_TITLES_OPTIONS,
buildAnimeVisibilityOptions, buildAnimeVisibilityOptions,
filterHiddenAnimeData, filterHiddenAnimeData,
loadHiddenTitles,
loadMaxTitles,
loadMaxTitlesMode,
loadShowEmptyDays,
pruneHiddenAnime, pruneHiddenAnime,
saveHiddenTitles,
saveMaxTitles,
saveMaxTitlesMode,
saveShowEmptyDays,
type MaxTitlesMode,
} from './anime-visibility'; } from './anime-visibility';
import { LibrarySummarySection } from './LibrarySummarySection'; import { LibrarySummarySection } from './LibrarySummarySection';
const MAX_TITLES_MODE_LABELS: Record<MaxTitlesMode, string> = {
recent: 'most recent',
total: 'top',
};
function SectionHeader({ children }: { children: React.ReactNode }) { function SectionHeader({ children }: { children: React.ReactNode }) {
return ( return (
<div className="col-span-full mt-6 mb-2 flex items-center gap-3"> <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 { interface AnimeVisibilityFilterProps {
animeTitles: string[]; animeTitles: string[];
hiddenAnime: ReadonlySet<string>; hiddenAnime: ReadonlySet<string>;
maxTitles: number | null;
maxTitlesMode: MaxTitlesMode;
onShowAll: () => void; onShowAll: () => void;
onHideAll: () => void; onHideAll: () => void;
onToggleAnime: (title: string) => void; onToggleAnime: (title: string) => void;
onMaxTitlesChange: (value: number | null) => void;
onMaxTitlesModeChange: (mode: MaxTitlesMode) => void;
} }
export function AnimeVisibilityFilter({ export function AnimeVisibilityFilter({
animeTitles, animeTitles,
hiddenAnime, hiddenAnime,
maxTitles,
maxTitlesMode,
onShowAll, onShowAll,
onHideAll, onHideAll,
onToggleAnime, onToggleAnime,
onMaxTitlesChange,
onMaxTitlesModeChange,
}: AnimeVisibilityFilterProps) { }: AnimeVisibilityFilterProps) {
if (animeTitles.length === 0) { if (animeTitles.length === 0) {
return null; return null;
@@ -48,10 +72,41 @@ export function AnimeVisibilityFilter({
Title Visibility Title Visibility
</h4> </h4>
<p className="mt-1 text-xs text-ctp-overlay1"> <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> </p>
</div> </div>
<div className="flex items-center gap-2"> <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 <button
type="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" 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() { export function TrendsTab() {
const [range, setRange] = useState<TimeRange>('30d'); const [range, setRange] = useState<TimeRange>('30d');
const [groupBy, setGroupBy] = useState<GroupBy>('day'); const [groupBy, setGroupBy] = useState<GroupBy>('day');
const [hiddenAnime, setHiddenAnime] = useState<Set<string>>(() => new Set()); const [showEmptyDays, setShowEmptyDays] = useState(() => loadShowEmptyDays());
const { data, loading, error } = useTrends(range, groupBy); 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 cardsMinedColor = 'var(--color-ctp-cards-mined)';
const cardsMinedStackedColors = [ const cardsMinedStackedColors = [
cardsMinedColor, cardsMinedColor,
@@ -144,8 +219,10 @@ export function TrendsTab() {
<DateRangeSelector <DateRangeSelector
range={range} range={range}
groupBy={groupBy} groupBy={groupBy}
fillEmpty={showEmptyDays}
onRangeChange={setRange} onRangeChange={setRange}
onGroupByChange={setGroupBy} onGroupByChange={setGroupBy}
onFillEmptyChange={updateShowEmptyDays}
/> />
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<SectionHeader>Activity (per {groupBy === 'month' ? 'month' : 'day'})</SectionHeader> <SectionHeader>Activity (per {groupBy === 'month' ? 'month' : 'day'})</SectionHeader>
@@ -246,28 +323,47 @@ export function TrendsTab() {
<AnimeVisibilityFilter <AnimeVisibilityFilter
animeTitles={animeTitles} animeTitles={animeTitles}
hiddenAnime={activeHiddenAnime} hiddenAnime={activeHiddenAnime}
onShowAll={() => setHiddenAnime(new Set())} maxTitles={maxTitles}
onHideAll={() => setHiddenAnime(new Set(animeTitles))} maxTitlesMode={maxTitlesMode}
onToggleAnime={(title) => onShowAll={() => updateHiddenAnime(new Set())}
setHiddenAnime((current) => { onHideAll={() => updateHiddenAnime(new Set(animeTitles))}
const next = new Set(current); onToggleAnime={(title) => {
if (next.has(title)) { const next = new Set(hiddenAnime);
next.delete(title); if (next.has(title)) {
} else { next.delete(title);
next.add(title); } else {
} next.add(title);
return next; }
}) 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 <StackedTrendChart
title="Cards Mined Progress" title="Cards Mined Progress"
data={filteredCardsProgress} data={filteredCardsProgress}
colorPalette={cardsMinedStackedColors} 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> <SectionHeader>Library Summary</SectionHeader>
<LibrarySummarySection rows={data.librarySummary} hiddenTitles={activeHiddenAnime} /> <LibrarySummarySection rows={data.librarySummary} hiddenTitles={activeHiddenAnime} />
@@ -5,9 +5,40 @@ import type { PerAnimeDataPoint } from './StackedTrendChart';
import { import {
buildAnimeVisibilityOptions, buildAnimeVisibilityOptions,
filterHiddenAnimeData, filterHiddenAnimeData,
loadHiddenTitles,
loadMaxTitles,
loadMaxTitlesMode,
loadShowEmptyDays,
pruneHiddenAnime, pruneHiddenAnime,
saveHiddenTitles,
saveMaxTitles,
saveMaxTitlesMode,
saveShowEmptyDays,
} from './anime-visibility'; } 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[] = [ const SAMPLE_POINTS: PerAnimeDataPoint[] = [
{ epochDay: 1, animeTitle: 'KonoSuba', value: 5 }, { epochDay: 1, animeTitle: 'KonoSuba', value: 5 },
{ epochDay: 2, animeTitle: 'KonoSuba', value: 10 }, { epochDay: 2, animeTitle: 'KonoSuba', value: 10 },
@@ -45,3 +76,86 @@ test('pruneHiddenAnime drops titles that are no longer available', () => {
assert.deepEqual([...hidden], ['KonoSuba']); 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'; 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[] { export function buildAnimeVisibilityOptions(datasets: PerAnimeDataPoint[][]): string[] {
const totals = new Map<string, number>(); const totals = new Map<string, number>();
for (const dataset of datasets) { 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 TimeRange = '7d' | '30d' | '90d' | '365d' | 'all';
export type GroupBy = 'day' | 'month'; 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 [data, setData] = useState<TrendsDashboardData | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -15,7 +15,7 @@ export function useTrends(range: TimeRange, groupBy: GroupBy) {
setLoading(true); setLoading(true);
setError(null); setError(null);
getStatsClient() getStatsClient()
.getTrendsDashboard(range, groupBy) .getTrendsDashboard(range, groupBy, fillEmpty)
.then((nextData) => { .then((nextData) => {
if (cancelled) return; if (cancelled) return;
setData(nextData); setData(nextData);
@@ -31,7 +31,7 @@ export function useTrends(range: TimeRange, groupBy: GroupBy) {
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [range, groupBy]); }, [range, groupBy, fillEmpty]);
return { data, loading, error }; return { data, loading, error };
} }
+9 -3
View File
@@ -167,7 +167,10 @@ test('getTrendsDashboard requests the chart-ready trends endpoint with range and
try { try {
await apiClient.getTrendsDashboard('90d', 'month'); 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 { } finally {
globalThis.fetch = originalFetch; globalThis.fetch = originalFetch;
} }
@@ -208,8 +211,11 @@ test('getTrendsDashboard accepts 365d range and builds correct URL', async () =>
}) as typeof globalThis.fetch; }) as typeof globalThis.fetch;
try { try {
await apiClient.getTrendsDashboard('365d', 'day'); await apiClient.getTrendsDashboard('365d', 'day', false);
assert.equal(seenUrl, `${BASE_URL}/api/stats/trends/dashboard?range=365d&groupBy=day`); assert.equal(
seenUrl,
`${BASE_URL}/api/stats/trends/dashboard?range=365d&groupBy=day&fillEmpty=false`,
);
} finally { } finally {
globalThis.fetch = originalFetch; 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}`), fetchJson<NewAnimePerDay[]>(`/api/stats/trends/new-anime-per-day?limit=${limit}`),
getWatchTimePerAnime: (limit = 90) => getWatchTimePerAnime: (limit = 90) =>
fetchJson<WatchTimePerAnime[]>(`/api/stats/trends/watch-time-per-anime?limit=${limit}`), 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>( 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) => getWordDetail: (wordId: number) =>
fetchJson<WordDetailData>(`/api/stats/vocabulary/${wordId}/detail`), fetchJson<WordDetailData>(`/api/stats/vocabulary/${wordId}/detail`),