mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-04-28 04:19:27 -07:00
feat(stats): dashboard updates (#50)
This commit is contained in:
@@ -84,14 +84,7 @@ test('getTrendsDashboard requests the chart-ready trends endpoint with range and
|
||||
lookups: [],
|
||||
},
|
||||
ratios: { lookupsPerHundred: [] },
|
||||
animePerDay: {
|
||||
episodes: [],
|
||||
watchTime: [],
|
||||
cards: [],
|
||||
words: [],
|
||||
lookups: [],
|
||||
lookupsPerHundred: [],
|
||||
},
|
||||
librarySummary: [],
|
||||
animeCumulative: {
|
||||
watchTime: [],
|
||||
episodes: [],
|
||||
@@ -115,6 +108,48 @@ test('getTrendsDashboard requests the chart-ready trends endpoint with range and
|
||||
}
|
||||
});
|
||||
|
||||
test('getTrendsDashboard accepts 365d range and builds correct URL', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
let seenUrl = '';
|
||||
globalThis.fetch = (async (input: RequestInfo | URL) => {
|
||||
seenUrl = String(input);
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
activity: { watchTime: [], cards: [], words: [], sessions: [] },
|
||||
progress: {
|
||||
watchTime: [],
|
||||
sessions: [],
|
||||
words: [],
|
||||
newWords: [],
|
||||
cards: [],
|
||||
episodes: [],
|
||||
lookups: [],
|
||||
},
|
||||
ratios: { lookupsPerHundred: [] },
|
||||
librarySummary: [],
|
||||
animeCumulative: {
|
||||
watchTime: [],
|
||||
episodes: [],
|
||||
cards: [],
|
||||
words: [],
|
||||
},
|
||||
patterns: {
|
||||
watchTimeByDayOfWeek: [],
|
||||
watchTimeByHour: [],
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
||||
);
|
||||
}) as typeof globalThis.fetch;
|
||||
|
||||
try {
|
||||
await apiClient.getTrendsDashboard('365d', 'day');
|
||||
assert.equal(seenUrl, `${BASE_URL}/api/stats/trends/dashboard?range=365d&groupBy=day`);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test('getSessionEvents can request only specific event types', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
let seenUrl = '';
|
||||
|
||||
@@ -116,7 +116,7 @@ export const apiClient = {
|
||||
fetchJson<NewAnimePerDay[]>(`/api/stats/trends/new-anime-per-day?limit=${limit}`),
|
||||
getWatchTimePerAnime: (limit = 90) =>
|
||||
fetchJson<WatchTimePerAnime[]>(`/api/stats/trends/watch-time-per-anime?limit=${limit}`),
|
||||
getTrendsDashboard: (range: '7d' | '30d' | '90d' | 'all', groupBy: 'day' | 'month') =>
|
||||
getTrendsDashboard: (range: '7d' | '30d' | '90d' | '365d' | 'all', groupBy: 'day' | 'month') =>
|
||||
fetchJson<TrendsDashboardData>(
|
||||
`/api/stats/trends/dashboard?range=${encodeURIComponent(range)}&groupBy=${encodeURIComponent(groupBy)}`,
|
||||
),
|
||||
|
||||
16
stats/src/lib/chart-theme.test.ts
Normal file
16
stats/src/lib/chart-theme.test.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { CHART_DEFAULTS, CHART_THEME, TOOLTIP_CONTENT_STYLE } from './chart-theme';
|
||||
|
||||
test('CHART_THEME exposes a grid color', () => {
|
||||
assert.equal(CHART_THEME.grid, '#494d64');
|
||||
});
|
||||
|
||||
test('CHART_DEFAULTS uses 11px ticks for legibility', () => {
|
||||
assert.equal(CHART_DEFAULTS.tickFontSize, 11);
|
||||
});
|
||||
|
||||
test('TOOLTIP_CONTENT_STYLE mirrors the shared tooltip colors', () => {
|
||||
assert.equal(TOOLTIP_CONTENT_STYLE.background, CHART_THEME.tooltipBg);
|
||||
assert.ok(String(TOOLTIP_CONTENT_STYLE.border).includes(CHART_THEME.tooltipBorder));
|
||||
});
|
||||
@@ -5,4 +5,21 @@ export const CHART_THEME = {
|
||||
tooltipText: '#cad3f5',
|
||||
tooltipLabel: '#b8c0e0',
|
||||
barFill: '#8aadf4',
|
||||
grid: '#494d64',
|
||||
axisLine: '#494d64',
|
||||
} as const;
|
||||
|
||||
export const CHART_DEFAULTS = {
|
||||
height: 160,
|
||||
tickFontSize: 11,
|
||||
margin: { top: 8, right: 8, bottom: 0, left: 0 },
|
||||
grid: { strokeDasharray: '3 3', vertical: false },
|
||||
} as const;
|
||||
|
||||
export const TOOLTIP_CONTENT_STYLE = {
|
||||
background: CHART_THEME.tooltipBg,
|
||||
border: `1px solid ${CHART_THEME.tooltipBorder}`,
|
||||
borderRadius: 6,
|
||||
color: CHART_THEME.tooltipText,
|
||||
fontSize: 12,
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
confirmBucketDelete,
|
||||
confirmDayGroupDelete,
|
||||
confirmEpisodeDelete,
|
||||
confirmSessionDelete,
|
||||
@@ -54,6 +55,42 @@ test('confirmDayGroupDelete uses singular for one session', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('confirmBucketDelete asks about merging multiple sessions of the same episode', () => {
|
||||
const calls: string[] = [];
|
||||
const originalConfirm = globalThis.confirm;
|
||||
globalThis.confirm = ((message?: string) => {
|
||||
calls.push(message ?? '');
|
||||
return true;
|
||||
}) as typeof globalThis.confirm;
|
||||
|
||||
try {
|
||||
assert.equal(confirmBucketDelete('My Episode', 3), true);
|
||||
assert.deepEqual(calls, [
|
||||
'Delete all 3 sessions of "My Episode" from this day and all associated data?',
|
||||
]);
|
||||
} finally {
|
||||
globalThis.confirm = originalConfirm;
|
||||
}
|
||||
});
|
||||
|
||||
test('confirmBucketDelete uses a clean singular form for one session', () => {
|
||||
const calls: string[] = [];
|
||||
const originalConfirm = globalThis.confirm;
|
||||
globalThis.confirm = ((message?: string) => {
|
||||
calls.push(message ?? '');
|
||||
return false;
|
||||
}) as typeof globalThis.confirm;
|
||||
|
||||
try {
|
||||
assert.equal(confirmBucketDelete('Solo Episode', 1), false);
|
||||
assert.deepEqual(calls, [
|
||||
'Delete this session of "Solo Episode" from this day and all associated data?',
|
||||
]);
|
||||
} finally {
|
||||
globalThis.confirm = originalConfirm;
|
||||
}
|
||||
});
|
||||
|
||||
test('confirmEpisodeDelete includes the episode title in the shared warning copy', () => {
|
||||
const calls: string[] = [];
|
||||
const originalConfirm = globalThis.confirm;
|
||||
|
||||
@@ -17,3 +17,14 @@ export function confirmAnimeGroupDelete(title: string, count: number): boolean {
|
||||
export function confirmEpisodeDelete(title: string): boolean {
|
||||
return globalThis.confirm(`Delete "${title}" and all its sessions?`);
|
||||
}
|
||||
|
||||
export function confirmBucketDelete(title: string, count: number): boolean {
|
||||
if (count === 1) {
|
||||
return globalThis.confirm(
|
||||
`Delete this session of "${title}" from this day and all associated data?`,
|
||||
);
|
||||
}
|
||||
return globalThis.confirm(
|
||||
`Delete all ${count} sessions of "${title}" from this day and all associated data?`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -46,9 +46,10 @@ test('buildSessionChartEvents keeps only chart-relevant events and pairs pause r
|
||||
{ eventType: EventType.LOOKUP, tsMs: 8_000, payload: '{"hit":true}' },
|
||||
]);
|
||||
|
||||
// Seek events are intentionally dropped from the chart — they were too noisy.
|
||||
assert.deepEqual(
|
||||
chartEvents.seekEvents.map((event) => event.eventType),
|
||||
[EventType.SEEK_FORWARD, EventType.SEEK_BACKWARD],
|
||||
chartEvents.markers.filter((marker) => marker.kind !== 'pause' && marker.kind !== 'card'),
|
||||
[],
|
||||
);
|
||||
assert.deepEqual(
|
||||
chartEvents.cardEvents.map((event) => event.tsMs),
|
||||
|
||||
@@ -29,25 +29,20 @@ test('buildSessionChartEvents produces typed hover markers with parsed payload m
|
||||
{ eventType: EventType.YOMITAN_LOOKUP, tsMs: 7_000, payload: null },
|
||||
]);
|
||||
|
||||
// Seek events are intentionally dropped — too noisy on the session chart.
|
||||
assert.deepEqual(
|
||||
chartEvents.markers.map((marker) => marker.kind),
|
||||
['seek', 'pause', 'card'],
|
||||
['pause', 'card'],
|
||||
);
|
||||
|
||||
const seekMarker = chartEvents.markers[0]!;
|
||||
assert.equal(seekMarker.kind, 'seek');
|
||||
assert.equal(seekMarker.direction, 'forward');
|
||||
assert.equal(seekMarker.fromMs, 1_000);
|
||||
assert.equal(seekMarker.toMs, 5_500);
|
||||
|
||||
const pauseMarker = chartEvents.markers[1]!;
|
||||
const pauseMarker = chartEvents.markers[0]!;
|
||||
assert.equal(pauseMarker.kind, 'pause');
|
||||
assert.equal(pauseMarker.startMs, 2_000);
|
||||
assert.equal(pauseMarker.endMs, 5_000);
|
||||
assert.equal(pauseMarker.durationMs, 3_000);
|
||||
assert.equal(pauseMarker.anchorTsMs, 3_500);
|
||||
|
||||
const cardMarker = chartEvents.markers[2]!;
|
||||
const cardMarker = chartEvents.markers[1]!;
|
||||
assert.equal(cardMarker.kind, 'card');
|
||||
assert.deepEqual(cardMarker.noteIds, [11, 22]);
|
||||
assert.equal(cardMarker.cardsDelta, 2);
|
||||
|
||||
@@ -2,8 +2,6 @@ import { EventType, type SessionEvent } from '../types/stats';
|
||||
|
||||
export const SESSION_CHART_EVENT_TYPES = [
|
||||
EventType.CARD_MINED,
|
||||
EventType.SEEK_FORWARD,
|
||||
EventType.SEEK_BACKWARD,
|
||||
EventType.PAUSE_START,
|
||||
EventType.PAUSE_END,
|
||||
EventType.YOMITAN_LOOKUP,
|
||||
@@ -16,7 +14,6 @@ export interface PauseRegion {
|
||||
|
||||
export interface SessionChartEvents {
|
||||
cardEvents: SessionEvent[];
|
||||
seekEvents: SessionEvent[];
|
||||
yomitanLookupEvents: SessionEvent[];
|
||||
pauseRegions: PauseRegion[];
|
||||
markers: SessionChartMarker[];
|
||||
@@ -58,15 +55,6 @@ export type SessionChartMarker =
|
||||
endMs: number;
|
||||
durationMs: number;
|
||||
}
|
||||
| {
|
||||
key: string;
|
||||
kind: 'seek';
|
||||
anchorTsMs: number;
|
||||
eventTsMs: number;
|
||||
direction: 'forward' | 'backward';
|
||||
fromMs: number | null;
|
||||
toMs: number | null;
|
||||
}
|
||||
| {
|
||||
key: string;
|
||||
kind: 'card';
|
||||
@@ -295,7 +283,6 @@ export function projectSessionMarkerLeftPx({
|
||||
|
||||
export function buildSessionChartEvents(events: SessionEvent[]): SessionChartEvents {
|
||||
const cardEvents: SessionEvent[] = [];
|
||||
const seekEvents: SessionEvent[] = [];
|
||||
const yomitanLookupEvents: SessionEvent[] = [];
|
||||
const pauseRegions: PauseRegion[] = [];
|
||||
const markers: SessionChartMarker[] = [];
|
||||
@@ -317,22 +304,6 @@ export function buildSessionChartEvents(events: SessionEvent[]): SessionChartEve
|
||||
});
|
||||
}
|
||||
break;
|
||||
case EventType.SEEK_FORWARD:
|
||||
case EventType.SEEK_BACKWARD:
|
||||
seekEvents.push(event);
|
||||
{
|
||||
const payload = parsePayload(event.payload);
|
||||
markers.push({
|
||||
key: `seek-${event.tsMs}-${event.eventType}`,
|
||||
kind: 'seek',
|
||||
anchorTsMs: event.tsMs,
|
||||
eventTsMs: event.tsMs,
|
||||
direction: event.eventType === EventType.SEEK_BACKWARD ? 'backward' : 'forward',
|
||||
fromMs: readNumberField(payload?.fromMs),
|
||||
toMs: readNumberField(payload?.toMs),
|
||||
});
|
||||
}
|
||||
break;
|
||||
case EventType.YOMITAN_LOOKUP:
|
||||
yomitanLookupEvents.push(event);
|
||||
break;
|
||||
@@ -376,7 +347,6 @@ export function buildSessionChartEvents(events: SessionEvent[]): SessionChartEve
|
||||
|
||||
return {
|
||||
cardEvents,
|
||||
seekEvents,
|
||||
yomitanLookupEvents,
|
||||
pauseRegions,
|
||||
markers,
|
||||
|
||||
96
stats/src/lib/session-grouping.test.ts
Normal file
96
stats/src/lib/session-grouping.test.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import type { SessionSummary } from '../types/stats';
|
||||
import { groupSessionsByVideo } from './session-grouping';
|
||||
|
||||
function makeSession(overrides: Partial<SessionSummary> & { sessionId: number }): SessionSummary {
|
||||
return {
|
||||
sessionId: overrides.sessionId,
|
||||
canonicalTitle: null,
|
||||
videoId: null,
|
||||
animeId: null,
|
||||
animeTitle: null,
|
||||
startedAtMs: 1000,
|
||||
endedAtMs: null,
|
||||
totalWatchedMs: 0,
|
||||
activeWatchedMs: 0,
|
||||
linesSeen: 0,
|
||||
tokensSeen: 0,
|
||||
cardsMined: 0,
|
||||
lookupCount: 0,
|
||||
lookupHits: 0,
|
||||
yomitanLookupCount: 0,
|
||||
knownWordsSeen: 0,
|
||||
knownWordRate: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('empty input returns empty array', () => {
|
||||
assert.deepEqual(groupSessionsByVideo([]), []);
|
||||
});
|
||||
|
||||
test('two unique videoIds produce 2 singleton buckets', () => {
|
||||
const sessions = [
|
||||
makeSession({
|
||||
sessionId: 1,
|
||||
videoId: 10,
|
||||
startedAtMs: 1000,
|
||||
activeWatchedMs: 100,
|
||||
cardsMined: 2,
|
||||
}),
|
||||
makeSession({
|
||||
sessionId: 2,
|
||||
videoId: 20,
|
||||
startedAtMs: 2000,
|
||||
activeWatchedMs: 200,
|
||||
cardsMined: 3,
|
||||
}),
|
||||
];
|
||||
const buckets = groupSessionsByVideo(sessions);
|
||||
assert.equal(buckets.length, 2);
|
||||
const keys = buckets.map((b) => b.key).sort();
|
||||
assert.deepEqual(keys, ['v-10', 'v-20']);
|
||||
for (const bucket of buckets) {
|
||||
assert.equal(bucket.sessions.length, 1);
|
||||
}
|
||||
});
|
||||
|
||||
test('two sessions sharing a videoId collapse into 1 bucket with summed totals and most-recent representative', () => {
|
||||
const older = makeSession({
|
||||
sessionId: 1,
|
||||
videoId: 42,
|
||||
startedAtMs: 1000,
|
||||
activeWatchedMs: 300,
|
||||
cardsMined: 5,
|
||||
});
|
||||
const newer = makeSession({
|
||||
sessionId: 2,
|
||||
videoId: 42,
|
||||
startedAtMs: 9000,
|
||||
activeWatchedMs: 500,
|
||||
cardsMined: 7,
|
||||
});
|
||||
const buckets = groupSessionsByVideo([older, newer]);
|
||||
assert.equal(buckets.length, 1);
|
||||
const [bucket] = buckets;
|
||||
assert.equal(bucket!.key, 'v-42');
|
||||
assert.equal(bucket!.videoId, 42);
|
||||
assert.equal(bucket!.sessions.length, 2);
|
||||
assert.equal(bucket!.totalActiveMs, 800);
|
||||
assert.equal(bucket!.totalCardsMined, 12);
|
||||
assert.equal(bucket!.representativeSession.sessionId, 2); // most recent (highest startedAtMs)
|
||||
});
|
||||
|
||||
test('sessions with null videoId become singleton buckets keyed by sessionId', () => {
|
||||
const s1 = makeSession({ sessionId: 101, videoId: null, activeWatchedMs: 50, cardsMined: 1 });
|
||||
const s2 = makeSession({ sessionId: 202, videoId: null, activeWatchedMs: 75, cardsMined: 2 });
|
||||
const buckets = groupSessionsByVideo([s1, s2]);
|
||||
assert.equal(buckets.length, 2);
|
||||
const keys = buckets.map((b) => b.key).sort();
|
||||
assert.deepEqual(keys, ['s-101', 's-202']);
|
||||
for (const bucket of buckets) {
|
||||
assert.equal(bucket.videoId, null);
|
||||
assert.equal(bucket.sessions.length, 1);
|
||||
}
|
||||
});
|
||||
43
stats/src/lib/session-grouping.ts
Normal file
43
stats/src/lib/session-grouping.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import type { SessionSummary } from '../types/stats';
|
||||
|
||||
export interface SessionBucket {
|
||||
key: string;
|
||||
videoId: number | null;
|
||||
sessions: SessionSummary[];
|
||||
totalActiveMs: number;
|
||||
totalCardsMined: number;
|
||||
representativeSession: SessionSummary;
|
||||
}
|
||||
|
||||
export function groupSessionsByVideo(sessions: SessionSummary[]): SessionBucket[] {
|
||||
const byKey = new Map<string, SessionSummary[]>();
|
||||
for (const session of sessions) {
|
||||
const hasVideoId =
|
||||
typeof session.videoId === 'number' &&
|
||||
Number.isFinite(session.videoId) &&
|
||||
session.videoId > 0;
|
||||
const key = hasVideoId ? `v-${session.videoId}` : `s-${session.sessionId}`;
|
||||
const existing = byKey.get(key);
|
||||
if (existing) existing.push(session);
|
||||
else byKey.set(key, [session]);
|
||||
}
|
||||
|
||||
const buckets: SessionBucket[] = [];
|
||||
for (const [key, group] of byKey) {
|
||||
const sorted = [...group].sort((a, b) => b.startedAtMs - a.startedAtMs);
|
||||
const representative = sorted[0]!;
|
||||
buckets.push({
|
||||
key,
|
||||
videoId:
|
||||
typeof representative.videoId === 'number' && representative.videoId > 0
|
||||
? representative.videoId
|
||||
: null,
|
||||
sessions: sorted,
|
||||
totalActiveMs: sorted.reduce((s, x) => s + x.activeWatchedMs, 0),
|
||||
totalCardsMined: sorted.reduce((s, x) => s + x.cardsMined, 0),
|
||||
representativeSession: representative,
|
||||
});
|
||||
}
|
||||
|
||||
return buckets;
|
||||
}
|
||||
Reference in New Issue
Block a user