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:
@@ -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;
|
||||||
return {
|
ratioByBucket.set(bucketKey, words > 0 ? +((lookups / words) * 100).toFixed(1) : 0);
|
||||||
label: groupBy === 'month' ? makeTrendLabel(bucketKey) : dayLabel(bucketKey),
|
}
|
||||||
value: words > 0 ? +((lookups / words) * 100).toFixed(1) : 0,
|
return fillAxisPoints(axis, ratioByBucket);
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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>();
|
return fillAxisPoints(axis, counts);
|
||||||
videoIds.add(rollup.videoId);
|
|
||||||
byMonth.set(rollup.rollupDayOrMonth, videoIds);
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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']);
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
const next = new Set(hiddenAnime);
|
||||||
if (next.has(title)) {
|
if (next.has(title)) {
|
||||||
next.delete(title);
|
next.delete(title);
|
||||||
} else {
|
} else {
|
||||||
next.add(title);
|
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) {
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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`),
|
||||||
|
|||||||
Reference in New Issue
Block a user