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:
@@ -729,7 +729,7 @@ describe('stats server API routes', () => {
|
||||
const res = await app.request('/api/stats/trends/dashboard?range=90d&groupBy=month');
|
||||
assert.equal(res.status, 200);
|
||||
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.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');
|
||||
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 () => {
|
||||
@@ -763,7 +763,25 @@ describe('stats server API routes', () => {
|
||||
|
||||
const res = await app.request('/api/stats/trends/dashboard?range=weird&groupBy=year');
|
||||
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 () => {
|
||||
|
||||
@@ -571,8 +571,9 @@ export class ImmersionTrackerService {
|
||||
async getTrendsDashboard(
|
||||
range: '7d' | '30d' | '90d' | '365d' | 'all' = '30d',
|
||||
groupBy: 'day' | 'month' = 'day',
|
||||
fillEmptyBuckets = true,
|
||||
): Promise<unknown> {
|
||||
return getTrendsDashboard(this.db, range, groupBy);
|
||||
return getTrendsDashboard(this.db, range, groupBy, fillEmptyBuckets);
|
||||
}
|
||||
|
||||
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(
|
||||
'getTrendsDashboard supports 365d range and caps day buckets at 365',
|
||||
{ timeout: 20_000 },
|
||||
@@ -1266,7 +1381,10 @@ test('getTrendsDashboard month grouping spans every touched calendar month and k
|
||||
|
||||
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(
|
||||
dashboard.progress.newWords.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');
|
||||
}
|
||||
|
||||
// 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[] {
|
||||
let sum = 0;
|
||||
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<
|
||||
number,
|
||||
{ activeMin: number; cards: number; words: number; sessions: number }
|
||||
@@ -236,15 +289,17 @@ function buildAggregatedTrendRows(rollups: ImmersionSessionRollupRow[]) {
|
||||
byKey.set(rollup.rollupDayOrMonth, existing);
|
||||
}
|
||||
|
||||
return Array.from(byKey.entries())
|
||||
.sort(([left], [right]) => left - right)
|
||||
.map(([key, value]) => ({
|
||||
const keys = axis ?? Array.from(byKey.keys()).sort((left, right) => left - right);
|
||||
return keys.map((key) => {
|
||||
const value = byKey.get(key) ?? { activeMin: 0, cards: 0, words: 0, sessions: 0 };
|
||||
return {
|
||||
label: makeTrendLabel(key),
|
||||
activeMin: Math.round(value.activeMin),
|
||||
cards: value.cards,
|
||||
words: value.words,
|
||||
sessions: value.sessions,
|
||||
}));
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function buildEfficiencyRates(rows: ReturnType<typeof buildAggregatedTrendRows>): {
|
||||
@@ -289,40 +344,24 @@ function buildWatchTimeByHour(sessions: TrendSessionMetricRow[]): TrendChartPoin
|
||||
}));
|
||||
}
|
||||
|
||||
function dayLabel(epochDay: number): string {
|
||||
const { month, day } = dayPartsFromEpochDay(epochDay);
|
||||
return `${MONTH_NAMES[month - 1]} ${day}`;
|
||||
}
|
||||
|
||||
function buildSessionSeriesByDay(
|
||||
function buildSessionSeries(
|
||||
sessions: TrendSessionMetricRow[],
|
||||
groupBy: TrendGroupBy,
|
||||
getValue: (session: TrendSessionMetricRow) => number,
|
||||
axis: number[] | null,
|
||||
): TrendChartPoint[] {
|
||||
const byDay = new Map<number, number>();
|
||||
const byBucket = new Map<number, number>();
|
||||
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())
|
||||
.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 }));
|
||||
return fillAxisPoints(axis, byBucket);
|
||||
}
|
||||
|
||||
function buildLookupsPerHundredWords(
|
||||
sessions: TrendSessionMetricRow[],
|
||||
groupBy: TrendGroupBy,
|
||||
axis: number[] | null,
|
||||
): TrendChartPoint[] {
|
||||
const lookupsByBucket = new Map<number, number>();
|
||||
const wordsByBucket = new Map<number, number>();
|
||||
@@ -339,15 +378,12 @@ function buildLookupsPerHundredWords(
|
||||
);
|
||||
}
|
||||
|
||||
return Array.from(lookupsByBucket.entries())
|
||||
.sort(([left], [right]) => left - right)
|
||||
.map(([bucketKey, lookups]) => {
|
||||
const words = wordsByBucket.get(bucketKey) ?? 0;
|
||||
return {
|
||||
label: groupBy === 'month' ? makeTrendLabel(bucketKey) : dayLabel(bucketKey),
|
||||
value: words > 0 ? +((lookups / words) * 100).toFixed(1) : 0,
|
||||
};
|
||||
});
|
||||
const ratioByBucket = new Map<number, number>();
|
||||
for (const [bucketKey, lookups] of lookupsByBucket) {
|
||||
const words = wordsByBucket.get(bucketKey) ?? 0;
|
||||
ratioByBucket.set(bucketKey, words > 0 ? +((lookups / words) * 100).toFixed(1) : 0);
|
||||
}
|
||||
return fillAxisPoints(axis, ratioByBucket);
|
||||
}
|
||||
|
||||
function buildCumulativePerAnime(points: TrendPerAnimePoint[]): TrendPerAnimePoint[] {
|
||||
@@ -557,46 +593,26 @@ function buildEpisodesPerAnimeFromDailyRollups(
|
||||
return result;
|
||||
}
|
||||
|
||||
function buildEpisodesPerDayFromDailyRollups(
|
||||
function buildEpisodesSeriesFromRollups(
|
||||
rollups: ImmersionSessionRollupRow[],
|
||||
axis: number[] | null,
|
||||
): TrendChartPoint[] {
|
||||
const byDay = new Map<number, Set<number>>();
|
||||
const byBucket = new Map<number, Set<number>>();
|
||||
|
||||
for (const rollup of rollups) {
|
||||
if (rollup.videoId === null) {
|
||||
continue;
|
||||
}
|
||||
const videoIds = byDay.get(rollup.rollupDayOrMonth) ?? new Set<number>();
|
||||
const videoIds = byBucket.get(rollup.rollupDayOrMonth) ?? new Set<number>();
|
||||
videoIds.add(rollup.videoId);
|
||||
byDay.set(rollup.rollupDayOrMonth, videoIds);
|
||||
byBucket.set(rollup.rollupDayOrMonth, videoIds);
|
||||
}
|
||||
|
||||
return Array.from(byDay.entries())
|
||||
.sort(([left], [right]) => left - right)
|
||||
.map(([epochDay, videoIds]) => ({
|
||||
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);
|
||||
const counts = new Map<number, number>();
|
||||
for (const [bucketKey, videoIds] of byBucket) {
|
||||
counts.set(bucketKey, videoIds.size);
|
||||
}
|
||||
|
||||
return Array.from(byMonth.entries())
|
||||
.sort(([left], [right]) => left - right)
|
||||
.map(([monthKey, videoIds]) => ({
|
||||
label: makeTrendLabel(monthKey),
|
||||
value: videoIds.size,
|
||||
}));
|
||||
return fillAxisPoints(axis, counts);
|
||||
}
|
||||
|
||||
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 prepared = db.prepare(`
|
||||
SELECT
|
||||
@@ -662,13 +682,15 @@ function buildNewWordsPerDay(db: DatabaseSync, cutoffMs: string | null): TrendCh
|
||||
wordCount: number;
|
||||
}>;
|
||||
|
||||
return rows.map((row) => ({
|
||||
label: dayLabel(row.epochDay),
|
||||
value: row.wordCount,
|
||||
}));
|
||||
const byBucket = new Map<number, number>(rows.map((row) => [row.epochDay, row.wordCount]));
|
||||
return fillAxisPoints(axis, byBucket);
|
||||
}
|
||||
|
||||
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 prepared = db.prepare(`
|
||||
SELECT
|
||||
@@ -691,16 +713,15 @@ function buildNewWordsPerMonth(db: DatabaseSync, cutoffMs: string | null): Trend
|
||||
wordCount: number;
|
||||
}>;
|
||||
|
||||
return rows.map((row) => ({
|
||||
label: makeTrendLabel(row.monthKey),
|
||||
value: row.wordCount,
|
||||
}));
|
||||
const byBucket = new Map<number, number>(rows.map((row) => [row.monthKey, row.wordCount]));
|
||||
return fillAxisPoints(axis, byBucket);
|
||||
}
|
||||
|
||||
export function getTrendsDashboard(
|
||||
db: DatabaseSync,
|
||||
range: TrendRange = '30d',
|
||||
groupBy: TrendGroupBy = 'day',
|
||||
fillEmptyBuckets = true,
|
||||
): TrendsDashboardQueryResult {
|
||||
const dayLimit = getTrendDayLimit(range);
|
||||
const monthlyLimit = getTrendMonthlyLimit(db, range);
|
||||
@@ -708,6 +729,11 @@ export function getTrendsDashboard(
|
||||
const useMonthlyBuckets = groupBy === 'month';
|
||||
const dailyRollups = getDailyRollups(db, dayLimit);
|
||||
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 sessions = getTrendSessionMetrics(db, cutoffMs);
|
||||
@@ -716,7 +742,7 @@ export function getTrendsDashboard(
|
||||
dailyRollups.map((rollup) => rollup.videoId),
|
||||
);
|
||||
|
||||
const aggregatedRows = buildAggregatedTrendRows(chartRollups);
|
||||
const aggregatedRows = buildAggregatedTrendRows(chartRollups, bucketAxis);
|
||||
const efficiency = buildEfficiencyRates(aggregatedRows);
|
||||
const activity = {
|
||||
watchTime: aggregatedRows.map((row) => ({ label: row.label, value: row.activeMin })),
|
||||
@@ -751,22 +777,23 @@ export function getTrendsDashboard(
|
||||
sessions: accumulatePoints(activity.sessions),
|
||||
words: accumulatePoints(activity.words),
|
||||
newWords: accumulatePoints(
|
||||
useMonthlyBuckets ? buildNewWordsPerMonth(db, cutoffMs) : buildNewWordsPerDay(db, cutoffMs),
|
||||
useMonthlyBuckets
|
||||
? buildNewWordsPerMonth(db, cutoffMs, bucketAxis)
|
||||
: buildNewWordsPerDay(db, cutoffMs, bucketAxis),
|
||||
),
|
||||
cards: accumulatePoints(activity.cards),
|
||||
episodes: accumulatePoints(
|
||||
useMonthlyBuckets
|
||||
? buildEpisodesPerMonthFromRollups(monthlyRollups)
|
||||
: buildEpisodesPerDayFromDailyRollups(dailyRollups),
|
||||
buildEpisodesSeriesFromRollups(
|
||||
useMonthlyBuckets ? monthlyRollups : dailyRollups,
|
||||
bucketAxis,
|
||||
),
|
||||
),
|
||||
lookups: accumulatePoints(
|
||||
useMonthlyBuckets
|
||||
? buildSessionSeriesByMonth(sessions, (session) => session.yomitanLookupCount)
|
||||
: buildSessionSeriesByDay(sessions, (session) => session.yomitanLookupCount),
|
||||
buildSessionSeries(sessions, groupBy, (session) => session.yomitanLookupCount, bucketAxis),
|
||||
),
|
||||
},
|
||||
ratios: {
|
||||
lookupsPerHundred: buildLookupsPerHundredWords(sessions, groupBy),
|
||||
lookupsPerHundred: buildLookupsPerHundredWords(sessions, groupBy, bucketAxis),
|
||||
cardsPerHour: efficiency.cardsPerHour,
|
||||
readingSpeed: efficiency.readingSpeed,
|
||||
},
|
||||
|
||||
@@ -81,6 +81,12 @@ function parseTrendGroupBy(raw: string | undefined): 'day' | 'month' {
|
||||
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 {
|
||||
if (!raw) return undefined;
|
||||
const parsed = raw
|
||||
@@ -685,7 +691,8 @@ export function createStatsApp(
|
||||
app.get('/api/stats/trends/dashboard', async (c) => {
|
||||
const range = parseTrendRange(c.req.query('range'));
|
||||
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) => {
|
||||
|
||||
Reference in New Issue
Block a user