mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-04-10 16:19:24 -07:00
feat(stats): dashboard updates (#50)
This commit is contained in:
@@ -93,7 +93,7 @@ export function AnimeTab({
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search anime..."
|
||||
placeholder="Search library..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="flex-1 bg-ctp-surface0 border border-ctp-surface1 rounded-lg px-3 py-2 text-sm text-ctp-text placeholder:text-ctp-overlay2 focus:outline-none focus:border-ctp-blue"
|
||||
@@ -125,12 +125,12 @@ export function AnimeTab({
|
||||
))}
|
||||
</div>
|
||||
<div className="text-xs text-ctp-overlay2 shrink-0">
|
||||
{filtered.length} anime · {formatDuration(totalMs)}
|
||||
{filtered.length} titles · {formatDuration(totalMs)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<div className="text-sm text-ctp-overlay2 p-4">No anime found</div>
|
||||
<div className="text-sm text-ctp-overlay2 p-4">No titles found</div>
|
||||
) : (
|
||||
<div className={`grid ${GRID_CLASSES[cardSize]} gap-4`}>
|
||||
{filtered.map((item) => (
|
||||
|
||||
60
stats/src/components/anime/EpisodeDetail.test.tsx
Normal file
60
stats/src/components/anime/EpisodeDetail.test.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { filterCardEvents } from './EpisodeDetail';
|
||||
import type { EpisodeCardEvent } from '../../types/stats';
|
||||
|
||||
function makeEvent(over: Partial<EpisodeCardEvent> & { eventId: number }): EpisodeCardEvent {
|
||||
return {
|
||||
sessionId: 1,
|
||||
tsMs: 0,
|
||||
cardsDelta: 1,
|
||||
noteIds: [],
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
test('filterCardEvents: before load, returns all events unchanged', () => {
|
||||
const ev1 = makeEvent({ eventId: 1, noteIds: [101] });
|
||||
const ev2 = makeEvent({ eventId: 2, noteIds: [102] });
|
||||
const noteInfos = new Map(); // empty — simulates pre-load state
|
||||
const result = filterCardEvents([ev1, ev2], noteInfos, /* noteInfosLoaded */ false);
|
||||
assert.equal(result.length, 2, 'should return both events before load');
|
||||
assert.deepEqual(result[0]?.noteIds, [101]);
|
||||
assert.deepEqual(result[1]?.noteIds, [102]);
|
||||
});
|
||||
|
||||
test('filterCardEvents: after load, drops noteIds not in noteInfos', () => {
|
||||
const ev1 = makeEvent({ eventId: 1, noteIds: [101] }); // survives
|
||||
const ev2 = makeEvent({ eventId: 2, noteIds: [102] }); // deleted from Anki
|
||||
const noteInfos = new Map([[101, { noteId: 101, expression: '食べる' }]]);
|
||||
const result = filterCardEvents([ev1, ev2], noteInfos, /* noteInfosLoaded */ true);
|
||||
assert.equal(result.length, 1, 'should drop event whose noteId was deleted from Anki');
|
||||
assert.equal(result[0]?.eventId, 1);
|
||||
assert.deepEqual(result[0]?.noteIds, [101]);
|
||||
});
|
||||
|
||||
test('filterCardEvents: after load, legacy rollup events (empty noteIds, positive cardsDelta) are kept', () => {
|
||||
const rollup = makeEvent({ eventId: 3, noteIds: [], cardsDelta: 5 });
|
||||
const noteInfos = new Map<number, { noteId: number; expression: string }>();
|
||||
const result = filterCardEvents([rollup], noteInfos, true);
|
||||
assert.equal(result.length, 1, 'legacy rollup event should survive filtering');
|
||||
assert.equal(result[0]?.cardsDelta, 5);
|
||||
});
|
||||
|
||||
test('filterCardEvents: after load, event with multiple noteIds keeps surviving ones', () => {
|
||||
const ev = makeEvent({ eventId: 4, noteIds: [201, 202, 203] });
|
||||
const noteInfos = new Map([
|
||||
[201, { noteId: 201, expression: 'A' }],
|
||||
[203, { noteId: 203, expression: 'C' }],
|
||||
]);
|
||||
const result = filterCardEvents([ev], noteInfos, true);
|
||||
assert.equal(result.length, 1, 'event with surviving noteIds should be kept');
|
||||
assert.deepEqual(result[0]?.noteIds, [201, 203], 'only surviving noteIds should remain');
|
||||
});
|
||||
|
||||
test('filterCardEvents: after load, event where all noteIds deleted is dropped', () => {
|
||||
const ev = makeEvent({ eventId: 5, noteIds: [301, 302] });
|
||||
const noteInfos = new Map<number, { noteId: number; expression: string }>();
|
||||
const result = filterCardEvents([ev], noteInfos, true);
|
||||
assert.equal(result.length, 0, 'event with all noteIds deleted should be dropped');
|
||||
});
|
||||
@@ -16,10 +16,32 @@ interface NoteInfo {
|
||||
expression: string;
|
||||
}
|
||||
|
||||
export function filterCardEvents(
|
||||
cardEvents: EpisodeDetailData['cardEvents'],
|
||||
noteInfos: Map<number, NoteInfo>,
|
||||
noteInfosLoaded: boolean,
|
||||
): EpisodeDetailData['cardEvents'] {
|
||||
if (!noteInfosLoaded) return cardEvents;
|
||||
return cardEvents
|
||||
.map((ev) => {
|
||||
// Legacy rollup events: no noteIds, just a cardsDelta count — keep as-is.
|
||||
if (ev.noteIds.length === 0) return ev;
|
||||
const survivingNoteIds = ev.noteIds.filter((id) => noteInfos.has(id));
|
||||
return { ...ev, noteIds: survivingNoteIds };
|
||||
})
|
||||
.filter((ev, i) => {
|
||||
// If the event originally had noteIds, only keep it if some survived.
|
||||
if ((cardEvents[i]?.noteIds.length ?? 0) > 0) return ev.noteIds.length > 0;
|
||||
// Legacy rollup event (originally no noteIds): keep if it has a positive delta.
|
||||
return ev.cardsDelta > 0;
|
||||
});
|
||||
}
|
||||
|
||||
export function EpisodeDetail({ videoId, onSessionDeleted }: EpisodeDetailProps) {
|
||||
const [data, setData] = useState<EpisodeDetailData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [noteInfos, setNoteInfos] = useState<Map<number, NoteInfo>>(new Map());
|
||||
const [noteInfosLoaded, setNoteInfosLoaded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -41,8 +63,14 @@ export function EpisodeDetail({ videoId, onSessionDeleted }: EpisodeDetailProps)
|
||||
map.set(note.noteId, { noteId: note.noteId, expression: expr });
|
||||
}
|
||||
setNoteInfos(map);
|
||||
setNoteInfosLoaded(true);
|
||||
})
|
||||
.catch((err) => console.warn('Failed to fetch Anki note info:', err));
|
||||
.catch((err) => {
|
||||
console.warn('Failed to fetch Anki note info:', err);
|
||||
if (!cancelled) setNoteInfosLoaded(true);
|
||||
});
|
||||
} else {
|
||||
if (!cancelled) setNoteInfosLoaded(true);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
@@ -72,6 +100,16 @@ export function EpisodeDetail({ videoId, onSessionDeleted }: EpisodeDetailProps)
|
||||
|
||||
const { sessions, cardEvents } = data;
|
||||
|
||||
const filteredCardEvents = filterCardEvents(cardEvents, noteInfos, noteInfosLoaded);
|
||||
|
||||
const hiddenCardCount = noteInfosLoaded
|
||||
? cardEvents.reduce((sum, ev) => {
|
||||
if (ev.noteIds.length === 0) return sum;
|
||||
const surviving = ev.noteIds.filter((id) => noteInfos.has(id));
|
||||
return sum + (ev.noteIds.length - surviving.length);
|
||||
}, 0)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div className="bg-ctp-mantle border border-ctp-surface1 rounded-lg">
|
||||
{sessions.length > 0 && (
|
||||
@@ -106,11 +144,11 @@ export function EpisodeDetail({ videoId, onSessionDeleted }: EpisodeDetailProps)
|
||||
</div>
|
||||
)}
|
||||
|
||||
{cardEvents.length > 0 && (
|
||||
{filteredCardEvents.length > 0 && (
|
||||
<div className="p-3 border-b border-ctp-surface1">
|
||||
<h4 className="text-xs font-semibold text-ctp-subtext0 mb-2">Cards Mined</h4>
|
||||
<div className="space-y-1.5">
|
||||
{cardEvents.map((ev) => (
|
||||
{filteredCardEvents.map((ev) => (
|
||||
<div key={ev.eventId} className="flex items-center gap-2 text-xs">
|
||||
<span className="text-ctp-overlay2 shrink-0">{formatRelativeDate(ev.tsMs)}</span>
|
||||
{ev.noteIds.length > 0 ? (
|
||||
@@ -144,6 +182,12 @@ export function EpisodeDetail({ videoId, onSessionDeleted }: EpisodeDetailProps)
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{hiddenCardCount > 0 && (
|
||||
<div className="px-3 pb-3 -mt-1 text-[10px] text-ctp-overlay2 italic">
|
||||
{hiddenCardCount} {hiddenCardCount === 1 ? 'card' : 'cards'} hidden (deleted from
|
||||
Anki)
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useMediaLibrary } from '../../hooks/useMediaLibrary';
|
||||
import { formatDuration, formatNumber } from '../../lib/formatters';
|
||||
import {
|
||||
groupMediaLibraryItems,
|
||||
summarizeMediaLibraryGroups,
|
||||
} from '../../lib/media-library-grouping';
|
||||
import { CoverImage } from './CoverImage';
|
||||
import { MediaCard } from './MediaCard';
|
||||
import { MediaDetailView } from './MediaDetailView';
|
||||
|
||||
interface LibraryTabProps {
|
||||
onNavigateToSession: (sessionId: number) => void;
|
||||
}
|
||||
|
||||
export function LibraryTab({ onNavigateToSession }: LibraryTabProps) {
|
||||
const { media, loading, error } = useMediaLibrary();
|
||||
const [search, setSearch] = useState('');
|
||||
const [selectedVideoId, setSelectedVideoId] = useState<number | null>(null);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!search.trim()) return media;
|
||||
const q = search.toLowerCase();
|
||||
return media.filter((m) => {
|
||||
const haystacks = [
|
||||
m.canonicalTitle,
|
||||
m.videoTitle,
|
||||
m.channelName,
|
||||
m.uploaderId,
|
||||
m.channelId,
|
||||
].filter(Boolean);
|
||||
return haystacks.some((value) => value!.toLowerCase().includes(q));
|
||||
});
|
||||
}, [media, search]);
|
||||
const grouped = useMemo(() => groupMediaLibraryItems(filtered), [filtered]);
|
||||
const summary = useMemo(() => summarizeMediaLibraryGroups(grouped), [grouped]);
|
||||
|
||||
if (selectedVideoId !== null) {
|
||||
return <MediaDetailView videoId={selectedVideoId} onBack={() => setSelectedVideoId(null)} />;
|
||||
}
|
||||
|
||||
if (loading) return <div className="text-ctp-overlay2 p-4">Loading...</div>;
|
||||
if (error) return <div className="text-ctp-red p-4">Error: {error}</div>;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search titles..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="flex-1 bg-ctp-surface0 border border-ctp-surface1 rounded-lg px-3 py-2 text-sm text-ctp-text placeholder:text-ctp-overlay2 focus:outline-none focus:border-ctp-blue"
|
||||
/>
|
||||
<div className="text-xs text-ctp-overlay2 shrink-0">
|
||||
{grouped.length} group{grouped.length !== 1 ? 's' : ''} · {summary.totalVideos} video
|
||||
{summary.totalVideos !== 1 ? 's' : ''} · {formatDuration(summary.totalMs)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<div className="text-sm text-ctp-overlay2 p-4">No media found</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{grouped.map((group) => (
|
||||
<section
|
||||
key={group.key}
|
||||
className="rounded-2xl border border-ctp-surface1 bg-ctp-surface0/70 overflow-hidden"
|
||||
>
|
||||
<div className="flex items-center gap-4 p-4 border-b border-ctp-surface1 bg-ctp-base/40">
|
||||
<CoverImage
|
||||
videoId={group.items[0]!.videoId}
|
||||
title={group.title}
|
||||
src={group.imageUrl}
|
||||
className="w-16 h-16 rounded-2xl shrink-0"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
{group.channelUrl ? (
|
||||
<a
|
||||
href={group.channelUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-base font-semibold text-ctp-text truncate hover:text-ctp-blue transition-colors"
|
||||
>
|
||||
{group.title}
|
||||
</a>
|
||||
) : (
|
||||
<h3 className="text-base font-semibold text-ctp-text truncate">
|
||||
{group.title}
|
||||
</h3>
|
||||
)}
|
||||
</div>
|
||||
{group.subtitle ? (
|
||||
<div className="text-xs text-ctp-overlay1 truncate mt-1">{group.subtitle}</div>
|
||||
) : null}
|
||||
<div className="text-xs text-ctp-overlay2 mt-2">
|
||||
{group.items.length} video{group.items.length !== 1 ? 's' : ''} ·{' '}
|
||||
{formatDuration(group.totalActiveMs)} · {formatNumber(group.totalCards)} cards
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||||
{group.items.map((item) => (
|
||||
<MediaCard
|
||||
key={item.videoId}
|
||||
item={item}
|
||||
onClick={() => setSelectedVideoId(item.videoId)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { getRelatedCollectionLabel } from './MediaDetailView';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { createElement } from 'react';
|
||||
import { getRelatedCollectionLabel, buildDeleteEpisodeHandler } from './MediaDetailView';
|
||||
|
||||
test('getRelatedCollectionLabel returns View Channel for youtube-backed media', () => {
|
||||
assert.equal(
|
||||
@@ -41,3 +43,85 @@ test('getRelatedCollectionLabel returns View Anime for non-youtube media', () =>
|
||||
'View Anime',
|
||||
);
|
||||
});
|
||||
|
||||
test('buildDeleteEpisodeHandler calls deleteVideo then onBack when confirm returns true', async () => {
|
||||
let deletedVideoId: number | null = null;
|
||||
let onBackCalled = false;
|
||||
|
||||
const fakeApiClient = {
|
||||
deleteVideo: async (id: number) => {
|
||||
deletedVideoId = id;
|
||||
},
|
||||
};
|
||||
|
||||
const fakeConfirm = (_title: string) => true;
|
||||
|
||||
const handler = buildDeleteEpisodeHandler({
|
||||
videoId: 42,
|
||||
title: 'Test Episode',
|
||||
apiClient: fakeApiClient as { deleteVideo: (id: number) => Promise<void> },
|
||||
confirmFn: fakeConfirm,
|
||||
onBack: () => {
|
||||
onBackCalled = true;
|
||||
},
|
||||
setDeleteError: () => {},
|
||||
});
|
||||
|
||||
await handler();
|
||||
assert.equal(deletedVideoId, 42);
|
||||
assert.equal(onBackCalled, true);
|
||||
});
|
||||
|
||||
test('buildDeleteEpisodeHandler does nothing when confirm returns false', async () => {
|
||||
let deletedVideoId: number | null = null;
|
||||
let onBackCalled = false;
|
||||
|
||||
const fakeApiClient = {
|
||||
deleteVideo: async (id: number) => {
|
||||
deletedVideoId = id;
|
||||
},
|
||||
};
|
||||
|
||||
const fakeConfirm = (_title: string) => false;
|
||||
|
||||
const handler = buildDeleteEpisodeHandler({
|
||||
videoId: 42,
|
||||
title: 'Test Episode',
|
||||
apiClient: fakeApiClient as { deleteVideo: (id: number) => Promise<void> },
|
||||
confirmFn: fakeConfirm,
|
||||
onBack: () => {
|
||||
onBackCalled = true;
|
||||
},
|
||||
setDeleteError: () => {},
|
||||
});
|
||||
|
||||
await handler();
|
||||
assert.equal(deletedVideoId, null);
|
||||
assert.equal(onBackCalled, false);
|
||||
});
|
||||
|
||||
test('buildDeleteEpisodeHandler sets error when deleteVideo throws', async () => {
|
||||
let capturedError: string | null = null;
|
||||
|
||||
const fakeApiClient = {
|
||||
deleteVideo: async (_id: number) => {
|
||||
throw new Error('Network failure');
|
||||
},
|
||||
};
|
||||
|
||||
const fakeConfirm = (_title: string) => true;
|
||||
|
||||
const handler = buildDeleteEpisodeHandler({
|
||||
videoId: 42,
|
||||
title: 'Test Episode',
|
||||
apiClient: fakeApiClient as { deleteVideo: (id: number) => Promise<void> },
|
||||
confirmFn: fakeConfirm,
|
||||
onBack: () => {},
|
||||
setDeleteError: (msg) => {
|
||||
capturedError = msg;
|
||||
},
|
||||
});
|
||||
|
||||
await handler();
|
||||
assert.equal(capturedError, 'Network failure');
|
||||
});
|
||||
|
||||
@@ -1,12 +1,48 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useMediaDetail } from '../../hooks/useMediaDetail';
|
||||
import { apiClient } from '../../lib/api-client';
|
||||
import { confirmSessionDelete } from '../../lib/delete-confirm';
|
||||
import { confirmSessionDelete, confirmEpisodeDelete } from '../../lib/delete-confirm';
|
||||
import { getSessionDisplayWordCount } from '../../lib/session-word-count';
|
||||
import { MediaHeader } from './MediaHeader';
|
||||
import { MediaSessionList } from './MediaSessionList';
|
||||
import type { MediaDetailData, SessionSummary } from '../../types/stats';
|
||||
|
||||
interface DeleteEpisodeHandlerOptions {
|
||||
videoId: number;
|
||||
title: string;
|
||||
apiClient: { deleteVideo: (id: number) => Promise<void> };
|
||||
confirmFn: (title: string) => boolean;
|
||||
onBack: () => void;
|
||||
setDeleteError: (msg: string | null) => void;
|
||||
/**
|
||||
* Ref used to guard against reentrant delete calls synchronously. When set,
|
||||
* a subsequent invocation while the previous request is still pending is
|
||||
* ignored so clicks during the await window can't trigger duplicate deletes.
|
||||
*/
|
||||
isDeletingRef?: { current: boolean };
|
||||
/** Optional React state setter so the UI can reflect the pending state. */
|
||||
setIsDeleting?: (value: boolean) => void;
|
||||
}
|
||||
|
||||
export function buildDeleteEpisodeHandler(opts: DeleteEpisodeHandlerOptions): () => Promise<void> {
|
||||
return async () => {
|
||||
if (opts.isDeletingRef?.current) return;
|
||||
if (!opts.confirmFn(opts.title)) return;
|
||||
if (opts.isDeletingRef) opts.isDeletingRef.current = true;
|
||||
opts.setIsDeleting?.(true);
|
||||
opts.setDeleteError(null);
|
||||
try {
|
||||
await opts.apiClient.deleteVideo(opts.videoId);
|
||||
opts.onBack();
|
||||
} catch (err) {
|
||||
opts.setDeleteError(err instanceof Error ? err.message : 'Failed to delete episode.');
|
||||
} finally {
|
||||
if (opts.isDeletingRef) opts.isDeletingRef.current = false;
|
||||
opts.setIsDeleting?.(false);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function getRelatedCollectionLabel(detail: MediaDetailData['detail']): string {
|
||||
if (detail?.channelName?.trim()) {
|
||||
return 'View Channel';
|
||||
@@ -35,6 +71,8 @@ export function MediaDetailView({
|
||||
const [localSessions, setLocalSessions] = useState<SessionSummary[] | null>(null);
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
const [deletingSessionId, setDeletingSessionId] = useState<number | null>(null);
|
||||
const [isDeletingEpisode, setIsDeletingEpisode] = useState(false);
|
||||
const isDeletingEpisodeRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
setLocalSessions(data?.sessions ?? null);
|
||||
@@ -79,6 +117,17 @@ export function MediaDetailView({
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteEpisode = buildDeleteEpisodeHandler({
|
||||
videoId,
|
||||
title: detail.canonicalTitle,
|
||||
apiClient,
|
||||
confirmFn: confirmEpisodeDelete,
|
||||
onBack,
|
||||
setDeleteError,
|
||||
isDeletingRef: isDeletingEpisodeRef,
|
||||
setIsDeleting: setIsDeletingEpisode,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -99,7 +148,11 @@ export function MediaDetailView({
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<MediaHeader detail={detail} />
|
||||
<MediaHeader
|
||||
detail={detail}
|
||||
onDeleteEpisode={handleDeleteEpisode}
|
||||
isDeletingEpisode={isDeletingEpisode}
|
||||
/>
|
||||
{deleteError ? <div className="text-sm text-ctp-red">{deleteError}</div> : null}
|
||||
<MediaSessionList
|
||||
sessions={sessions}
|
||||
|
||||
@@ -12,9 +12,16 @@ interface MediaHeaderProps {
|
||||
totalUniqueWords: number;
|
||||
knownWordCount: number;
|
||||
} | null;
|
||||
onDeleteEpisode?: () => void;
|
||||
isDeletingEpisode?: boolean;
|
||||
}
|
||||
|
||||
export function MediaHeader({ detail, initialKnownWordsSummary = null }: MediaHeaderProps) {
|
||||
export function MediaHeader({
|
||||
detail,
|
||||
initialKnownWordsSummary = null,
|
||||
onDeleteEpisode,
|
||||
isDeletingEpisode = false,
|
||||
}: MediaHeaderProps) {
|
||||
const knownTokenRate =
|
||||
detail.totalLookupCount > 0 ? detail.totalLookupHits / detail.totalLookupCount : null;
|
||||
const avgSessionMs =
|
||||
@@ -50,7 +57,21 @@ export function MediaHeader({ detail, initialKnownWordsSummary = null }: MediaHe
|
||||
className="w-32 h-44 rounded-lg shrink-0"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2 className="text-lg font-bold text-ctp-text truncate">{detail.canonicalTitle}</h2>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h2 className="min-w-0 flex-1 text-lg font-bold text-ctp-text truncate">
|
||||
{detail.canonicalTitle}
|
||||
</h2>
|
||||
{onDeleteEpisode != null ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDeleteEpisode}
|
||||
disabled={isDeletingEpisode}
|
||||
className="shrink-0 text-xs text-ctp-red hover:opacity-75 transition-opacity disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isDeletingEpisode ? 'Deleting...' : 'Delete Episode'}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{detail.channelName ? (
|
||||
<div className="mt-1 text-sm text-ctp-subtext1 truncate">
|
||||
{detail.channelUrl ? (
|
||||
|
||||
@@ -36,7 +36,7 @@ export function HeroStats({ summary, sessions }: HeroStatsProps) {
|
||||
/>
|
||||
<StatCard label="Current Streak" value={`${summary.streakDays}d`} color="text-ctp-peach" />
|
||||
<StatCard
|
||||
label="Active Anime"
|
||||
label="Active Titles"
|
||||
value={formatNumber(summary.activeAnimeCount)}
|
||||
color="text-ctp-mauve"
|
||||
/>
|
||||
|
||||
@@ -71,7 +71,7 @@ export function TrackingSnapshot({
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip text="Total unique episodes (videos) watched across all anime">
|
||||
<Tooltip text="Total unique videos watched across all titles in your library">
|
||||
<div className="rounded-lg bg-ctp-surface1/60 p-3">
|
||||
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Episodes</div>
|
||||
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-blue">
|
||||
@@ -79,9 +79,9 @@ export function TrackingSnapshot({
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip text="Number of anime series fully completed">
|
||||
<Tooltip text="Number of titles fully completed">
|
||||
<div className="rounded-lg bg-ctp-surface1/60 p-3">
|
||||
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Anime</div>
|
||||
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Titles</div>
|
||||
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-sapphire">
|
||||
{formatNumber(summary.totalAnimeCompleted)}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer } from 'recharts';
|
||||
import { BarChart, Bar, CartesianGrid, XAxis, YAxis, Tooltip, ResponsiveContainer } from 'recharts';
|
||||
import { epochDayToDate } from '../../lib/formatters';
|
||||
import { CHART_THEME } from '../../lib/chart-theme';
|
||||
import { CHART_DEFAULTS, CHART_THEME, TOOLTIP_CONTENT_STYLE } from '../../lib/chart-theme';
|
||||
import type { DailyRollup } from '../../types/stats';
|
||||
|
||||
interface WatchTimeChartProps {
|
||||
@@ -52,28 +52,23 @@ export function WatchTimeChart({ rollups }: WatchTimeChartProps) {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={160}>
|
||||
<BarChart data={chartData}>
|
||||
<ResponsiveContainer width="100%" height={CHART_DEFAULTS.height}>
|
||||
<BarChart data={chartData} margin={CHART_DEFAULTS.margin}>
|
||||
<CartesianGrid stroke={CHART_THEME.grid} {...CHART_DEFAULTS.grid} />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tick={{ fontSize: 10, fill: CHART_THEME.tick }}
|
||||
axisLine={false}
|
||||
tick={{ fontSize: CHART_DEFAULTS.tickFontSize, fill: CHART_THEME.tick }}
|
||||
axisLine={{ stroke: CHART_THEME.axisLine }}
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 10, fill: CHART_THEME.tick }}
|
||||
axisLine={false}
|
||||
tick={{ fontSize: CHART_DEFAULTS.tickFontSize, fill: CHART_THEME.tick }}
|
||||
axisLine={{ stroke: CHART_THEME.axisLine }}
|
||||
tickLine={false}
|
||||
width={30}
|
||||
width={32}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
background: CHART_THEME.tooltipBg,
|
||||
border: `1px solid ${CHART_THEME.tooltipBorder}`,
|
||||
borderRadius: 6,
|
||||
color: CHART_THEME.tooltipText,
|
||||
fontSize: 12,
|
||||
}}
|
||||
contentStyle={TOOLTIP_CONTENT_STYLE}
|
||||
labelStyle={{ color: CHART_THEME.tooltipLabel }}
|
||||
formatter={formatActiveMinutes}
|
||||
/>
|
||||
|
||||
@@ -125,14 +125,13 @@ export function SessionDetail({ session }: SessionDetailProps) {
|
||||
const knownWordsMap = buildKnownWordsLookup(knownWordsTimeline);
|
||||
const hasKnownWords = knownWordsMap.size > 0;
|
||||
|
||||
const { cardEvents, seekEvents, yomitanLookupEvents, pauseRegions, markers } =
|
||||
const { cardEvents, yomitanLookupEvents, pauseRegions, markers } =
|
||||
buildSessionChartEvents(events);
|
||||
const lookupRate = buildLookupRateDisplay(
|
||||
session.yomitanLookupCount,
|
||||
getSessionDisplayWordCount(session),
|
||||
);
|
||||
const pauseCount = events.filter((e) => e.eventType === EventType.PAUSE_START).length;
|
||||
const seekCount = seekEvents.length;
|
||||
const cardEventCount = cardEvents.length;
|
||||
const activeMarkerKey = resolveActiveSessionMarkerKey(hoveredMarkerKey, pinnedMarkerKey);
|
||||
const activeMarker = useMemo<SessionChartMarker | null>(
|
||||
@@ -230,7 +229,6 @@ export function SessionDetail({ session }: SessionDetailProps) {
|
||||
sorted={sorted}
|
||||
knownWordsMap={knownWordsMap}
|
||||
cardEvents={cardEvents}
|
||||
seekEvents={seekEvents}
|
||||
yomitanLookupEvents={yomitanLookupEvents}
|
||||
pauseRegions={pauseRegions}
|
||||
markers={markers}
|
||||
@@ -242,7 +240,6 @@ export function SessionDetail({ session }: SessionDetailProps) {
|
||||
loadingNoteIds={loadingNoteIds}
|
||||
onOpenNote={handleOpenNote}
|
||||
pauseCount={pauseCount}
|
||||
seekCount={seekCount}
|
||||
cardEventCount={cardEventCount}
|
||||
lookupRate={lookupRate}
|
||||
session={session}
|
||||
@@ -254,7 +251,6 @@ export function SessionDetail({ session }: SessionDetailProps) {
|
||||
<FallbackView
|
||||
sorted={sorted}
|
||||
cardEvents={cardEvents}
|
||||
seekEvents={seekEvents}
|
||||
yomitanLookupEvents={yomitanLookupEvents}
|
||||
pauseRegions={pauseRegions}
|
||||
markers={markers}
|
||||
@@ -266,7 +262,6 @@ export function SessionDetail({ session }: SessionDetailProps) {
|
||||
loadingNoteIds={loadingNoteIds}
|
||||
onOpenNote={handleOpenNote}
|
||||
pauseCount={pauseCount}
|
||||
seekCount={seekCount}
|
||||
cardEventCount={cardEventCount}
|
||||
lookupRate={lookupRate}
|
||||
session={session}
|
||||
@@ -280,7 +275,6 @@ function RatioView({
|
||||
sorted,
|
||||
knownWordsMap,
|
||||
cardEvents,
|
||||
seekEvents,
|
||||
yomitanLookupEvents,
|
||||
pauseRegions,
|
||||
markers,
|
||||
@@ -292,7 +286,6 @@ function RatioView({
|
||||
loadingNoteIds,
|
||||
onOpenNote,
|
||||
pauseCount,
|
||||
seekCount,
|
||||
cardEventCount,
|
||||
lookupRate,
|
||||
session,
|
||||
@@ -300,7 +293,6 @@ function RatioView({
|
||||
sorted: TimelineEntry[];
|
||||
knownWordsMap: Map<number, number>;
|
||||
cardEvents: SessionEvent[];
|
||||
seekEvents: SessionEvent[];
|
||||
yomitanLookupEvents: SessionEvent[];
|
||||
pauseRegions: Array<{ startMs: number; endMs: number }>;
|
||||
markers: SessionChartMarker[];
|
||||
@@ -312,7 +304,6 @@ function RatioView({
|
||||
loadingNoteIds: Set<number>;
|
||||
onOpenNote: (noteId: number) => void;
|
||||
pauseCount: number;
|
||||
seekCount: number;
|
||||
cardEventCount: number;
|
||||
lookupRate: ReturnType<typeof buildLookupRateDisplay>;
|
||||
session: SessionSummary;
|
||||
@@ -450,22 +441,6 @@ function RatioView({
|
||||
/>
|
||||
))}
|
||||
|
||||
{seekEvents.map((e, i) => {
|
||||
const isBackward = e.eventType === EventType.SEEK_BACKWARD;
|
||||
const stroke = isBackward ? '#f5bde6' : '#8bd5ca';
|
||||
return (
|
||||
<ReferenceLine
|
||||
key={`seek-${i}`}
|
||||
yAxisId="pct"
|
||||
x={e.tsMs}
|
||||
stroke={stroke}
|
||||
strokeWidth={1.5}
|
||||
strokeOpacity={0.75}
|
||||
strokeDasharray="4 3"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Yomitan lookup markers */}
|
||||
{yomitanLookupEvents.map((e, i) => (
|
||||
<ReferenceLine
|
||||
@@ -549,7 +524,6 @@ function RatioView({
|
||||
<StatsBar
|
||||
hasKnownWords
|
||||
pauseCount={pauseCount}
|
||||
seekCount={seekCount}
|
||||
cardEventCount={cardEventCount}
|
||||
session={session}
|
||||
lookupRate={lookupRate}
|
||||
@@ -563,7 +537,6 @@ function RatioView({
|
||||
function FallbackView({
|
||||
sorted,
|
||||
cardEvents,
|
||||
seekEvents,
|
||||
yomitanLookupEvents,
|
||||
pauseRegions,
|
||||
markers,
|
||||
@@ -575,14 +548,12 @@ function FallbackView({
|
||||
loadingNoteIds,
|
||||
onOpenNote,
|
||||
pauseCount,
|
||||
seekCount,
|
||||
cardEventCount,
|
||||
lookupRate,
|
||||
session,
|
||||
}: {
|
||||
sorted: TimelineEntry[];
|
||||
cardEvents: SessionEvent[];
|
||||
seekEvents: SessionEvent[];
|
||||
yomitanLookupEvents: SessionEvent[];
|
||||
pauseRegions: Array<{ startMs: number; endMs: number }>;
|
||||
markers: SessionChartMarker[];
|
||||
@@ -594,7 +565,6 @@ function FallbackView({
|
||||
loadingNoteIds: Set<number>;
|
||||
onOpenNote: (noteId: number) => void;
|
||||
pauseCount: number;
|
||||
seekCount: number;
|
||||
cardEventCount: number;
|
||||
lookupRate: ReturnType<typeof buildLookupRateDisplay>;
|
||||
session: SessionSummary;
|
||||
@@ -680,20 +650,6 @@ function FallbackView({
|
||||
strokeOpacity={0.8}
|
||||
/>
|
||||
))}
|
||||
{seekEvents.map((e, i) => {
|
||||
const isBackward = e.eventType === EventType.SEEK_BACKWARD;
|
||||
const stroke = isBackward ? '#f5bde6' : '#8bd5ca';
|
||||
return (
|
||||
<ReferenceLine
|
||||
key={`seek-${i}`}
|
||||
x={e.tsMs}
|
||||
stroke={stroke}
|
||||
strokeWidth={1.5}
|
||||
strokeOpacity={0.75}
|
||||
strokeDasharray="4 3"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{yomitanLookupEvents.map((e, i) => (
|
||||
<ReferenceLine
|
||||
key={`yomitan-${i}`}
|
||||
@@ -735,7 +691,6 @@ function FallbackView({
|
||||
<StatsBar
|
||||
hasKnownWords={false}
|
||||
pauseCount={pauseCount}
|
||||
seekCount={seekCount}
|
||||
cardEventCount={cardEventCount}
|
||||
session={session}
|
||||
lookupRate={lookupRate}
|
||||
@@ -749,14 +704,12 @@ function FallbackView({
|
||||
function StatsBar({
|
||||
hasKnownWords,
|
||||
pauseCount,
|
||||
seekCount,
|
||||
cardEventCount,
|
||||
session,
|
||||
lookupRate,
|
||||
}: {
|
||||
hasKnownWords: boolean;
|
||||
pauseCount: number;
|
||||
seekCount: number;
|
||||
cardEventCount: number;
|
||||
session: SessionSummary;
|
||||
lookupRate: ReturnType<typeof buildLookupRateDisplay>;
|
||||
@@ -791,12 +744,7 @@ function StatsBar({
|
||||
{pauseCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
)}
|
||||
{seekCount > 0 && (
|
||||
<span className="text-ctp-overlay2">
|
||||
<span className="text-ctp-teal">{seekCount}</span> seek{seekCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
)}
|
||||
{(pauseCount > 0 || seekCount > 0) && <span className="text-ctp-surface2">|</span>}
|
||||
{pauseCount > 0 && <span className="text-ctp-surface2">|</span>}
|
||||
|
||||
{/* Group 3: Learning events */}
|
||||
<span className="flex items-center gap-1.5">
|
||||
|
||||
@@ -33,8 +33,6 @@ function markerLabel(marker: SessionChartMarker): string {
|
||||
switch (marker.kind) {
|
||||
case 'pause':
|
||||
return '||';
|
||||
case 'seek':
|
||||
return marker.direction === 'backward' ? '<<' : '>>';
|
||||
case 'card':
|
||||
return '\u26CF';
|
||||
}
|
||||
@@ -44,10 +42,6 @@ function markerColors(marker: SessionChartMarker): { border: string; bg: string;
|
||||
switch (marker.kind) {
|
||||
case 'pause':
|
||||
return { border: '#f5a97f', bg: 'rgba(245,169,127,0.16)', text: '#f5a97f' };
|
||||
case 'seek':
|
||||
return marker.direction === 'backward'
|
||||
? { border: '#f5bde6', bg: 'rgba(245,189,230,0.16)', text: '#f5bde6' }
|
||||
: { border: '#8bd5ca', bg: 'rgba(139,213,202,0.16)', text: '#8bd5ca' };
|
||||
case 'card':
|
||||
return { border: '#a6da95', bg: 'rgba(166,218,149,0.16)', text: '#a6da95' };
|
||||
}
|
||||
|
||||
@@ -41,35 +41,6 @@ test('SessionEventPopover renders formatted card-mine details with fetched note
|
||||
assert.match(markup, /Open in Anki/);
|
||||
});
|
||||
|
||||
test('SessionEventPopover renders seek metadata compactly', () => {
|
||||
const marker: SessionChartMarker = {
|
||||
key: 'seek-3000',
|
||||
kind: 'seek',
|
||||
anchorTsMs: 3_000,
|
||||
eventTsMs: 3_000,
|
||||
direction: 'backward',
|
||||
fromMs: 5_000,
|
||||
toMs: 1_500,
|
||||
};
|
||||
|
||||
const markup = renderToStaticMarkup(
|
||||
<SessionEventPopover
|
||||
marker={marker}
|
||||
noteInfos={new Map()}
|
||||
loading={false}
|
||||
pinned={false}
|
||||
onTogglePinned={() => {}}
|
||||
onClose={() => {}}
|
||||
onOpenNote={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
assert.match(markup, /Seek backward/);
|
||||
assert.match(markup, /5\.0s/);
|
||||
assert.match(markup, /1\.5s/);
|
||||
assert.match(markup, /3\.5s/);
|
||||
});
|
||||
|
||||
test('SessionEventPopover renders a cleaner fallback when AnkiConnect provides no preview fields', () => {
|
||||
const marker: SessionChartMarker = {
|
||||
key: 'card-9000',
|
||||
|
||||
@@ -31,18 +31,12 @@ export function SessionEventPopover({
|
||||
onClose,
|
||||
onOpenNote,
|
||||
}: SessionEventPopoverProps) {
|
||||
const seekDurationLabel =
|
||||
marker.kind === 'seek' && marker.fromMs !== null && marker.toMs !== null
|
||||
? formatEventSeconds(Math.abs(marker.toMs - marker.fromMs))?.replace(/\.0s$/, 's')
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="relative z-50 w-64 rounded-xl border border-ctp-surface2 bg-ctp-surface0/95 p-3 shadow-2xl shadow-black/30 backdrop-blur-sm">
|
||||
<div className="mb-2 flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-xs font-semibold text-ctp-text">
|
||||
{marker.kind === 'pause' && 'Paused'}
|
||||
{marker.kind === 'seek' && `Seek ${marker.direction}`}
|
||||
{marker.kind === 'card' && 'Card mined'}
|
||||
</div>
|
||||
<div className="text-[10px] text-ctp-overlay1">{formatEventTime(marker.eventTsMs)}</div>
|
||||
@@ -72,7 +66,6 @@ export function SessionEventPopover({
|
||||
) : null}
|
||||
<div className="text-sm">
|
||||
{marker.kind === 'pause' && '||'}
|
||||
{marker.kind === 'seek' && (marker.direction === 'backward' ? '<<' : '>>')}
|
||||
{marker.kind === 'card' && '\u26CF'}
|
||||
</div>
|
||||
</div>
|
||||
@@ -84,19 +77,6 @@ export function SessionEventPopover({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{marker.kind === 'seek' && (
|
||||
<div className="space-y-1 text-xs text-ctp-subtext0">
|
||||
<div>
|
||||
From{' '}
|
||||
<span className="text-ctp-teal">{formatEventSeconds(marker.fromMs) ?? '\u2014'}</span>{' '}
|
||||
to <span className="text-ctp-teal">{formatEventSeconds(marker.toMs) ?? '\u2014'}</span>
|
||||
</div>
|
||||
<div>
|
||||
Length <span className="text-ctp-peach">{seekDurationLabel ?? '\u2014'}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{marker.kind === 'card' && (
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs text-ctp-cards-mined">
|
||||
|
||||
@@ -120,7 +120,7 @@ export function SessionRow({
|
||||
}}
|
||||
aria-label={`View overview for ${session.canonicalTitle ?? 'Unknown Media'}`}
|
||||
className="absolute right-10 top-1/2 -translate-y-1/2 w-5 h-5 rounded border border-ctp-surface2 text-transparent hover:border-ctp-blue/50 hover:text-ctp-blue hover:bg-ctp-blue/10 transition-colors opacity-0 group-hover:opacity-100 focus:opacity-100 flex items-center justify-center"
|
||||
title="View anime overview"
|
||||
title="View in Library"
|
||||
>
|
||||
{'\u2197'}
|
||||
</button>
|
||||
|
||||
150
stats/src/components/sessions/SessionsTab.test.tsx
Normal file
150
stats/src/components/sessions/SessionsTab.test.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import type { SessionBucket } from '../../lib/session-grouping';
|
||||
import type { SessionSummary } from '../../types/stats';
|
||||
import { buildBucketDeleteHandler } from './SessionsTab';
|
||||
|
||||
function makeSession(over: Partial<SessionSummary>): SessionSummary {
|
||||
return {
|
||||
sessionId: 1,
|
||||
videoId: 100,
|
||||
canonicalTitle: 'Episode 1',
|
||||
startedAtMs: 1_000_000,
|
||||
endedAtMs: 1_060_000,
|
||||
activeWatchedMs: 60_000,
|
||||
cardsMined: 1,
|
||||
linesSeen: 10,
|
||||
lookupCount: 5,
|
||||
lookupHits: 3,
|
||||
knownWordsSeen: 5,
|
||||
...over,
|
||||
} as SessionSummary;
|
||||
}
|
||||
|
||||
function makeBucket(sessions: SessionSummary[]): SessionBucket {
|
||||
const sorted = [...sessions].sort((a, b) => b.startedAtMs - a.startedAtMs);
|
||||
return {
|
||||
key: `v-${sorted[0]!.videoId}`,
|
||||
videoId: sorted[0]!.videoId ?? null,
|
||||
sessions: sorted,
|
||||
totalActiveMs: sorted.reduce((s, x) => s + x.activeWatchedMs, 0),
|
||||
totalCardsMined: sorted.reduce((s, x) => s + x.cardsMined, 0),
|
||||
representativeSession: sorted[0]!,
|
||||
};
|
||||
}
|
||||
|
||||
test('buildBucketDeleteHandler deletes every session in the bucket when confirm returns true', async () => {
|
||||
let deleted: number[] | null = null;
|
||||
let onSuccessCalledWith: number[] | null = null;
|
||||
let onErrorCalled = false;
|
||||
|
||||
const bucket = makeBucket([
|
||||
makeSession({ sessionId: 11, startedAtMs: 2_000_000 }),
|
||||
makeSession({ sessionId: 22, startedAtMs: 3_000_000 }),
|
||||
makeSession({ sessionId: 33, startedAtMs: 4_000_000 }),
|
||||
]);
|
||||
|
||||
const handler = buildBucketDeleteHandler({
|
||||
bucket,
|
||||
apiClient: {
|
||||
deleteSessions: async (ids: number[]) => {
|
||||
deleted = ids;
|
||||
},
|
||||
},
|
||||
confirm: (title, count) => {
|
||||
assert.equal(title, 'Episode 1');
|
||||
assert.equal(count, 3);
|
||||
return true;
|
||||
},
|
||||
onSuccess: (ids) => {
|
||||
onSuccessCalledWith = ids;
|
||||
},
|
||||
onError: () => {
|
||||
onErrorCalled = true;
|
||||
},
|
||||
});
|
||||
|
||||
await handler();
|
||||
|
||||
assert.deepEqual(deleted, [33, 22, 11]);
|
||||
assert.deepEqual(onSuccessCalledWith, [33, 22, 11]);
|
||||
assert.equal(onErrorCalled, false);
|
||||
});
|
||||
|
||||
test('buildBucketDeleteHandler is a no-op when confirm returns false', async () => {
|
||||
let deleteCalled = false;
|
||||
let successCalled = false;
|
||||
|
||||
const bucket = makeBucket([makeSession({ sessionId: 1 }), makeSession({ sessionId: 2 })]);
|
||||
|
||||
const handler = buildBucketDeleteHandler({
|
||||
bucket,
|
||||
apiClient: {
|
||||
deleteSessions: async () => {
|
||||
deleteCalled = true;
|
||||
},
|
||||
},
|
||||
confirm: () => false,
|
||||
onSuccess: () => {
|
||||
successCalled = true;
|
||||
},
|
||||
onError: () => {},
|
||||
});
|
||||
|
||||
await handler();
|
||||
|
||||
assert.equal(deleteCalled, false);
|
||||
assert.equal(successCalled, false);
|
||||
});
|
||||
|
||||
test('buildBucketDeleteHandler reports errors via onError without calling onSuccess', async () => {
|
||||
let errorMessage: string | null = null;
|
||||
let successCalled = false;
|
||||
|
||||
const bucket = makeBucket([makeSession({ sessionId: 1 }), makeSession({ sessionId: 2 })]);
|
||||
|
||||
const handler = buildBucketDeleteHandler({
|
||||
bucket,
|
||||
apiClient: {
|
||||
deleteSessions: async () => {
|
||||
throw new Error('boom');
|
||||
},
|
||||
},
|
||||
confirm: () => true,
|
||||
onSuccess: () => {
|
||||
successCalled = true;
|
||||
},
|
||||
onError: (message) => {
|
||||
errorMessage = message;
|
||||
},
|
||||
});
|
||||
|
||||
await handler();
|
||||
|
||||
assert.equal(errorMessage, 'boom');
|
||||
assert.equal(successCalled, false);
|
||||
});
|
||||
|
||||
test('buildBucketDeleteHandler falls back to a generic title when canonicalTitle is null', async () => {
|
||||
let seenTitle: string | null = null;
|
||||
|
||||
const bucket = makeBucket([
|
||||
makeSession({ sessionId: 1, canonicalTitle: null }),
|
||||
makeSession({ sessionId: 2, canonicalTitle: null }),
|
||||
]);
|
||||
|
||||
const handler = buildBucketDeleteHandler({
|
||||
bucket,
|
||||
apiClient: { deleteSessions: async () => {} },
|
||||
confirm: (title) => {
|
||||
seenTitle = title;
|
||||
return false;
|
||||
},
|
||||
onSuccess: () => {},
|
||||
onError: () => {},
|
||||
});
|
||||
|
||||
await handler();
|
||||
|
||||
assert.equal(seenTitle, 'this episode');
|
||||
});
|
||||
@@ -3,8 +3,9 @@ import { useSessions } from '../../hooks/useSessions';
|
||||
import { SessionRow } from './SessionRow';
|
||||
import { SessionDetail } from './SessionDetail';
|
||||
import { apiClient } from '../../lib/api-client';
|
||||
import { confirmSessionDelete } from '../../lib/delete-confirm';
|
||||
import { formatSessionDayLabel } from '../../lib/formatters';
|
||||
import { confirmBucketDelete, confirmSessionDelete } from '../../lib/delete-confirm';
|
||||
import { formatDuration, formatNumber, formatSessionDayLabel } from '../../lib/formatters';
|
||||
import { groupSessionsByVideo, type SessionBucket } from '../../lib/session-grouping';
|
||||
import type { SessionSummary } from '../../types/stats';
|
||||
|
||||
function groupSessionsByDay(sessions: SessionSummary[]): Map<string, SessionSummary[]> {
|
||||
@@ -23,6 +24,35 @@ function groupSessionsByDay(sessions: SessionSummary[]): Map<string, SessionSumm
|
||||
return groups;
|
||||
}
|
||||
|
||||
export interface BucketDeleteDeps {
|
||||
bucket: SessionBucket;
|
||||
apiClient: { deleteSessions: (ids: number[]) => Promise<void> };
|
||||
confirm: (title: string, count: number) => boolean;
|
||||
onSuccess: (deletedIds: number[]) => void;
|
||||
onError: (message: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a handler that deletes every session in a bucket after confirmation.
|
||||
*
|
||||
* Extracted as a pure factory so the deletion flow can be unit-tested without
|
||||
* rendering the full SessionsTab or mocking React state.
|
||||
*/
|
||||
export function buildBucketDeleteHandler(deps: BucketDeleteDeps): () => Promise<void> {
|
||||
const { bucket, apiClient: client, confirm, onSuccess, onError } = deps;
|
||||
return async () => {
|
||||
const title = bucket.representativeSession.canonicalTitle ?? 'this episode';
|
||||
const ids = bucket.sessions.map((s) => s.sessionId);
|
||||
if (!confirm(title, ids.length)) return;
|
||||
try {
|
||||
await client.deleteSessions(ids);
|
||||
onSuccess(ids);
|
||||
} catch (err) {
|
||||
onError(err instanceof Error ? err.message : 'Failed to delete sessions.');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
interface SessionsTabProps {
|
||||
initialSessionId?: number | null;
|
||||
onClearInitialSession?: () => void;
|
||||
@@ -36,10 +66,12 @@ export function SessionsTab({
|
||||
}: SessionsTabProps = {}) {
|
||||
const { sessions, loading, error } = useSessions();
|
||||
const [expandedId, setExpandedId] = useState<number | null>(null);
|
||||
const [expandedBuckets, setExpandedBuckets] = useState<Set<string>>(() => new Set());
|
||||
const [search, setSearch] = useState('');
|
||||
const [visibleSessions, setVisibleSessions] = useState<SessionSummary[]>([]);
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
const [deletingSessionId, setDeletingSessionId] = useState<number | null>(null);
|
||||
const [deletingBucketKey, setDeletingBucketKey] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setVisibleSessions(sessions);
|
||||
@@ -76,7 +108,16 @@ export function SessionsTab({
|
||||
return visibleSessions.filter((s) => s.canonicalTitle?.toLowerCase().includes(q));
|
||||
}, [visibleSessions, search]);
|
||||
|
||||
const groups = useMemo(() => groupSessionsByDay(filtered), [filtered]);
|
||||
const dayGroups = useMemo(() => groupSessionsByDay(filtered), [filtered]);
|
||||
|
||||
const toggleBucket = (key: string) => {
|
||||
setExpandedBuckets((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeleteSession = async (session: SessionSummary) => {
|
||||
if (!confirmSessionDelete()) return;
|
||||
@@ -94,6 +135,33 @@ export function SessionsTab({
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteBucket = async (bucket: SessionBucket) => {
|
||||
setDeleteError(null);
|
||||
setDeletingBucketKey(bucket.key);
|
||||
const handler = buildBucketDeleteHandler({
|
||||
bucket,
|
||||
apiClient,
|
||||
confirm: confirmBucketDelete,
|
||||
onSuccess: (ids) => {
|
||||
const deleted = new Set(ids);
|
||||
setVisibleSessions((prev) => prev.filter((s) => !deleted.has(s.sessionId)));
|
||||
setExpandedId((prev) => (prev != null && deleted.has(prev) ? null : prev));
|
||||
setExpandedBuckets((prev) => {
|
||||
if (!prev.has(bucket.key)) return prev;
|
||||
const next = new Set(prev);
|
||||
next.delete(bucket.key);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
onError: (message) => setDeleteError(message),
|
||||
});
|
||||
try {
|
||||
await handler();
|
||||
} finally {
|
||||
setDeletingBucketKey(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <div className="text-ctp-overlay2 p-4">Loading...</div>;
|
||||
if (error) return <div className="text-ctp-red p-4">Error: {error}</div>;
|
||||
|
||||
@@ -110,39 +178,120 @@ export function SessionsTab({
|
||||
|
||||
{deleteError ? <div className="text-sm text-ctp-red">{deleteError}</div> : null}
|
||||
|
||||
{Array.from(groups.entries()).map(([dayLabel, daySessions]) => (
|
||||
<div key={dayLabel}>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h3 className="text-xs font-semibold text-ctp-overlay2 uppercase tracking-widest shrink-0">
|
||||
{dayLabel}
|
||||
</h3>
|
||||
<div className="flex-1 h-px bg-gradient-to-r from-ctp-surface1 to-transparent" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{daySessions.map((s) => {
|
||||
const detailsId = `session-details-${s.sessionId}`;
|
||||
return (
|
||||
<div key={s.sessionId}>
|
||||
<SessionRow
|
||||
session={s}
|
||||
isExpanded={expandedId === s.sessionId}
|
||||
detailsId={detailsId}
|
||||
onToggle={() => setExpandedId(expandedId === s.sessionId ? null : s.sessionId)}
|
||||
onDelete={() => void handleDeleteSession(s)}
|
||||
deleteDisabled={deletingSessionId === s.sessionId}
|
||||
onNavigateToMediaDetail={onNavigateToMediaDetail}
|
||||
/>
|
||||
{expandedId === s.sessionId && (
|
||||
<div id={detailsId}>
|
||||
<SessionDetail session={s} />
|
||||
{Array.from(dayGroups.entries()).map(([dayLabel, daySessions]) => {
|
||||
const buckets = groupSessionsByVideo(daySessions);
|
||||
return (
|
||||
<div key={dayLabel}>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h3 className="text-xs font-semibold text-ctp-overlay2 uppercase tracking-widest shrink-0">
|
||||
{dayLabel}
|
||||
</h3>
|
||||
<div className="flex-1 h-px bg-gradient-to-r from-ctp-surface1 to-transparent" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{buckets.map((bucket) => {
|
||||
if (bucket.sessions.length === 1) {
|
||||
const s = bucket.sessions[0]!;
|
||||
const detailsId = `session-details-${s.sessionId}`;
|
||||
return (
|
||||
<div key={bucket.key}>
|
||||
<SessionRow
|
||||
session={s}
|
||||
isExpanded={expandedId === s.sessionId}
|
||||
detailsId={detailsId}
|
||||
onToggle={() =>
|
||||
setExpandedId(expandedId === s.sessionId ? null : s.sessionId)
|
||||
}
|
||||
onDelete={() => void handleDeleteSession(s)}
|
||||
deleteDisabled={deletingSessionId === s.sessionId}
|
||||
onNavigateToMediaDetail={onNavigateToMediaDetail}
|
||||
/>
|
||||
{expandedId === s.sessionId && (
|
||||
<div id={detailsId}>
|
||||
<SessionDetail session={s} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
);
|
||||
}
|
||||
|
||||
const bucketBodyId = `session-bucket-${bucket.key}`;
|
||||
const isExpanded = expandedBuckets.has(bucket.key);
|
||||
const title = bucket.representativeSession.canonicalTitle ?? 'Unknown Media';
|
||||
const deleteDisabled = deletingBucketKey === bucket.key;
|
||||
return (
|
||||
<div key={bucket.key}>
|
||||
<div className="relative group flex items-stretch gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleBucket(bucket.key)}
|
||||
aria-expanded={isExpanded}
|
||||
aria-controls={bucketBodyId}
|
||||
className="flex-1 bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-3 flex items-center gap-3 hover:border-ctp-surface2 transition-colors text-left"
|
||||
>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={`text-ctp-overlay2 text-xs shrink-0 transition-transform ${
|
||||
isExpanded ? 'rotate-90' : ''
|
||||
}`}
|
||||
>
|
||||
{'\u25B6'}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium text-ctp-text truncate">{title}</div>
|
||||
<div className="text-xs text-ctp-overlay2">
|
||||
{bucket.sessions.length} session
|
||||
{bucket.sessions.length === 1 ? '' : 's'} ·{' '}
|
||||
{formatDuration(bucket.totalActiveMs)} active ·{' '}
|
||||
{formatNumber(bucket.totalCardsMined)} cards
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleDeleteBucket(bucket)}
|
||||
disabled={deleteDisabled}
|
||||
aria-label={`Delete all ${bucket.sessions.length} sessions of ${title}`}
|
||||
title="Delete all sessions in this group"
|
||||
className="shrink-0 w-8 rounded-lg border border-ctp-surface1 bg-ctp-surface0 text-ctp-overlay2 hover:border-ctp-red/50 hover:text-ctp-red hover:bg-ctp-red/10 transition-colors flex items-center justify-center disabled:opacity-40 disabled:cursor-not-allowed opacity-0 group-hover:opacity-100 focus:opacity-100"
|
||||
>
|
||||
{'\u2715'}
|
||||
</button>
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<div id={bucketBodyId} className="mt-2 ml-6 space-y-2">
|
||||
{bucket.sessions.map((s) => {
|
||||
const detailsId = `session-details-${s.sessionId}`;
|
||||
return (
|
||||
<div key={s.sessionId}>
|
||||
<SessionRow
|
||||
session={s}
|
||||
isExpanded={expandedId === s.sessionId}
|
||||
detailsId={detailsId}
|
||||
onToggle={() =>
|
||||
setExpandedId(expandedId === s.sessionId ? null : s.sessionId)
|
||||
}
|
||||
onDelete={() => void handleDeleteSession(s)}
|
||||
deleteDisabled={deletingSessionId === s.sessionId}
|
||||
onNavigateToMediaDetail={onNavigateToMediaDetail}
|
||||
/>
|
||||
{expandedId === s.sessionId && (
|
||||
<div id={detailsId}>
|
||||
<SessionDetail session={s} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
|
||||
{filtered.length === 0 && (
|
||||
<div className="text-ctp-overlay2 text-sm">
|
||||
|
||||
@@ -53,7 +53,7 @@ export function DateRangeSelector({
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<SegmentedControl
|
||||
label="Range"
|
||||
options={['7d', '30d', '90d', 'all'] as TimeRange[]}
|
||||
options={['7d', '30d', '90d', '365d', 'all'] as TimeRange[]}
|
||||
value={range}
|
||||
onChange={onRangeChange}
|
||||
formatLabel={(r) => (r === 'all' ? 'All' : r)}
|
||||
|
||||
248
stats/src/components/trends/LibrarySummarySection.tsx
Normal file
248
stats/src/components/trends/LibrarySummarySection.tsx
Normal file
@@ -0,0 +1,248 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
|
||||
import type { LibrarySummaryRow } from '../../types/stats';
|
||||
import { CHART_DEFAULTS, CHART_THEME, TOOLTIP_CONTENT_STYLE } from '../../lib/chart-theme';
|
||||
import { epochDayToDate, formatDuration, formatNumber } from '../../lib/formatters';
|
||||
|
||||
interface LibrarySummarySectionProps {
|
||||
rows: LibrarySummaryRow[];
|
||||
hiddenTitles: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
const LEADERBOARD_LIMIT = 10;
|
||||
const LEADERBOARD_HEIGHT = 260;
|
||||
const LEADERBOARD_BAR_COLOR = '#8aadf4';
|
||||
const TABLE_MAX_HEIGHT = 480;
|
||||
|
||||
type SortColumn =
|
||||
| 'title'
|
||||
| 'watchTimeMin'
|
||||
| 'videos'
|
||||
| 'sessions'
|
||||
| 'cards'
|
||||
| 'words'
|
||||
| 'lookups'
|
||||
| 'lookupsPerHundred'
|
||||
| 'firstWatched';
|
||||
|
||||
type SortDirection = 'asc' | 'desc';
|
||||
|
||||
interface ColumnDef {
|
||||
id: SortColumn;
|
||||
label: string;
|
||||
align: 'left' | 'right';
|
||||
}
|
||||
|
||||
const COLUMNS: ColumnDef[] = [
|
||||
{ id: 'title', label: 'Title', align: 'left' },
|
||||
{ id: 'watchTimeMin', label: 'Watch Time', align: 'right' },
|
||||
{ id: 'videos', label: 'Videos', align: 'right' },
|
||||
{ id: 'sessions', label: 'Sessions', align: 'right' },
|
||||
{ id: 'cards', label: 'Cards', align: 'right' },
|
||||
{ id: 'words', label: 'Words', align: 'right' },
|
||||
{ id: 'lookups', label: 'Lookups', align: 'right' },
|
||||
{ id: 'lookupsPerHundred', label: 'Lookups/100w', align: 'right' },
|
||||
{ id: 'firstWatched', label: 'Date Range', align: 'right' },
|
||||
];
|
||||
|
||||
function truncateTitle(title: string, maxChars: number): string {
|
||||
if (title.length <= maxChars) return title;
|
||||
return `${title.slice(0, maxChars - 1)}…`;
|
||||
}
|
||||
|
||||
function formatDateRange(firstEpochDay: number, lastEpochDay: number): string {
|
||||
const fmt = (epochDay: number) =>
|
||||
epochDayToDate(epochDay).toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
if (firstEpochDay === lastEpochDay) return fmt(firstEpochDay);
|
||||
return `${fmt(firstEpochDay)} → ${fmt(lastEpochDay)}`;
|
||||
}
|
||||
|
||||
function formatWatchTime(min: number): string {
|
||||
return formatDuration(min * 60_000);
|
||||
}
|
||||
|
||||
function compareRows(
|
||||
a: LibrarySummaryRow,
|
||||
b: LibrarySummaryRow,
|
||||
column: SortColumn,
|
||||
direction: SortDirection,
|
||||
): number {
|
||||
const sign = direction === 'asc' ? 1 : -1;
|
||||
|
||||
if (column === 'title') {
|
||||
return a.title.localeCompare(b.title) * sign;
|
||||
}
|
||||
|
||||
if (column === 'firstWatched') {
|
||||
return (a.firstWatched - b.firstWatched) * sign;
|
||||
}
|
||||
|
||||
if (column === 'lookupsPerHundred') {
|
||||
const aVal = a.lookupsPerHundred;
|
||||
const bVal = b.lookupsPerHundred;
|
||||
if (aVal === null && bVal === null) return 0;
|
||||
if (aVal === null) return 1;
|
||||
if (bVal === null) return -1;
|
||||
return (aVal - bVal) * sign;
|
||||
}
|
||||
|
||||
const aVal = a[column] as number;
|
||||
const bVal = b[column] as number;
|
||||
return (aVal - bVal) * sign;
|
||||
}
|
||||
|
||||
export function LibrarySummarySection({ rows, hiddenTitles }: LibrarySummarySectionProps) {
|
||||
const [sortColumn, setSortColumn] = useState<SortColumn>('watchTimeMin');
|
||||
const [sortDirection, setSortDirection] = useState<SortDirection>('desc');
|
||||
|
||||
const visibleRows = useMemo(
|
||||
() => rows.filter((row) => !hiddenTitles.has(row.title)),
|
||||
[rows, hiddenTitles],
|
||||
);
|
||||
|
||||
const sortedRows = useMemo(
|
||||
() => [...visibleRows].sort((a, b) => compareRows(a, b, sortColumn, sortDirection)),
|
||||
[visibleRows, sortColumn, sortDirection],
|
||||
);
|
||||
|
||||
const leaderboard = useMemo(
|
||||
() =>
|
||||
[...visibleRows]
|
||||
.sort((a, b) => b.watchTimeMin - a.watchTimeMin)
|
||||
.slice(0, LEADERBOARD_LIMIT)
|
||||
.map((row) => ({
|
||||
title: row.title,
|
||||
displayTitle: truncateTitle(row.title, 24),
|
||||
watchTimeMin: row.watchTimeMin,
|
||||
})),
|
||||
[visibleRows],
|
||||
);
|
||||
|
||||
if (visibleRows.length === 0) {
|
||||
return (
|
||||
<div className="col-span-full rounded-lg border border-ctp-surface1 bg-ctp-surface0 p-4">
|
||||
<div className="text-xs text-ctp-overlay2">No library activity in the selected window.</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleHeaderClick = (column: SortColumn) => {
|
||||
if (column === sortColumn) {
|
||||
setSortDirection((prev) => (prev === 'asc' ? 'desc' : 'asc'));
|
||||
} else {
|
||||
setSortColumn(column);
|
||||
setSortDirection(column === 'title' ? 'asc' : 'desc');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="col-span-full rounded-lg border border-ctp-surface1 bg-ctp-surface0 p-4">
|
||||
<h3 className="text-xs font-semibold text-ctp-text mb-2">Top Titles by Watch Time (min)</h3>
|
||||
<ResponsiveContainer width="100%" height={LEADERBOARD_HEIGHT}>
|
||||
<BarChart
|
||||
data={leaderboard}
|
||||
layout="vertical"
|
||||
margin={{ top: 8, right: 16, bottom: 8, left: 8 }}
|
||||
>
|
||||
<CartesianGrid stroke={CHART_THEME.grid} {...CHART_DEFAULTS.grid} />
|
||||
<XAxis
|
||||
type="number"
|
||||
tick={{ fontSize: CHART_DEFAULTS.tickFontSize, fill: CHART_THEME.tick }}
|
||||
axisLine={{ stroke: CHART_THEME.axisLine }}
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="displayTitle"
|
||||
width={160}
|
||||
tick={{ fontSize: CHART_DEFAULTS.tickFontSize, fill: CHART_THEME.tick }}
|
||||
axisLine={{ stroke: CHART_THEME.axisLine }}
|
||||
tickLine={false}
|
||||
interval={0}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={TOOLTIP_CONTENT_STYLE}
|
||||
formatter={(value: number) => [`${value} min`, 'Watch Time']}
|
||||
labelFormatter={(_label, payload) => {
|
||||
const datum = payload?.[0]?.payload as { title?: string } | undefined;
|
||||
return datum?.title ?? '';
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="watchTimeMin" fill={LEADERBOARD_BAR_COLOR} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div className="col-span-full rounded-lg border border-ctp-surface1 bg-ctp-surface0 p-4">
|
||||
<h3 className="text-xs font-semibold text-ctp-text mb-2">Per-Title Summary</h3>
|
||||
<div className="overflow-auto" style={{ maxHeight: TABLE_MAX_HEIGHT }}>
|
||||
<table className="w-full text-xs">
|
||||
<thead className="sticky top-0 bg-ctp-surface0">
|
||||
<tr className="border-b border-ctp-surface1 text-ctp-subtext0">
|
||||
{COLUMNS.map((column) => {
|
||||
const isActive = column.id === sortColumn;
|
||||
const indicator = isActive ? (sortDirection === 'asc' ? ' ▲' : ' ▼') : '';
|
||||
return (
|
||||
<th
|
||||
key={column.id}
|
||||
scope="col"
|
||||
className={`px-2 py-2 font-medium select-none cursor-pointer hover:text-ctp-text ${
|
||||
column.align === 'right' ? 'text-right' : 'text-left'
|
||||
} ${isActive ? 'text-ctp-text' : ''}`}
|
||||
onClick={() => handleHeaderClick(column.id)}
|
||||
>
|
||||
{column.label}
|
||||
{indicator}
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedRows.map((row) => (
|
||||
<tr
|
||||
key={row.title}
|
||||
className="border-b border-ctp-surface1 last:border-b-0 hover:bg-ctp-surface1/40"
|
||||
>
|
||||
<td
|
||||
className="px-2 py-2 text-left text-ctp-text max-w-[240px] truncate"
|
||||
title={row.title}
|
||||
>
|
||||
{row.title}
|
||||
</td>
|
||||
<td className="px-2 py-2 text-right text-ctp-text tabular-nums">
|
||||
{formatWatchTime(row.watchTimeMin)}
|
||||
</td>
|
||||
<td className="px-2 py-2 text-right text-ctp-text tabular-nums">
|
||||
{formatNumber(row.videos)}
|
||||
</td>
|
||||
<td className="px-2 py-2 text-right text-ctp-text tabular-nums">
|
||||
{formatNumber(row.sessions)}
|
||||
</td>
|
||||
<td className="px-2 py-2 text-right text-ctp-text tabular-nums">
|
||||
{formatNumber(row.cards)}
|
||||
</td>
|
||||
<td className="px-2 py-2 text-right text-ctp-text tabular-nums">
|
||||
{formatNumber(row.words)}
|
||||
</td>
|
||||
<td className="px-2 py-2 text-right text-ctp-text tabular-nums">
|
||||
{formatNumber(row.lookups)}
|
||||
</td>
|
||||
<td className="px-2 py-2 text-right text-ctp-text tabular-nums">
|
||||
{row.lookupsPerHundred === null ? '—' : row.lookupsPerHundred.toFixed(1)}
|
||||
</td>
|
||||
<td className="px-2 py-2 text-right text-ctp-subtext0 tabular-nums">
|
||||
{formatDateRange(row.firstWatched, row.lastWatched)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,13 @@
|
||||
import { AreaChart, Area, XAxis, YAxis, Tooltip, ResponsiveContainer } from 'recharts';
|
||||
import {
|
||||
AreaChart,
|
||||
Area,
|
||||
CartesianGrid,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts';
|
||||
import { CHART_DEFAULTS, CHART_THEME, TOOLTIP_CONTENT_STYLE } from '../../lib/chart-theme';
|
||||
import { epochDayToDate } from '../../lib/formatters';
|
||||
|
||||
export interface PerAnimeDataPoint {
|
||||
@@ -64,14 +73,6 @@ export function StackedTrendChart({ title, data, colorPalette }: StackedTrendCha
|
||||
const { points, seriesKeys } = buildLineData(data);
|
||||
const colors = colorPalette ?? DEFAULT_LINE_COLORS;
|
||||
|
||||
const tooltipStyle = {
|
||||
background: '#363a4f',
|
||||
border: '1px solid #494d64',
|
||||
borderRadius: 6,
|
||||
color: '#cad3f5',
|
||||
fontSize: 12,
|
||||
};
|
||||
|
||||
if (points.length === 0) {
|
||||
return (
|
||||
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
|
||||
@@ -84,21 +85,22 @@ export function StackedTrendChart({ title, data, colorPalette }: StackedTrendCha
|
||||
return (
|
||||
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
|
||||
<h3 className="text-xs font-semibold text-ctp-text mb-2">{title}</h3>
|
||||
<ResponsiveContainer width="100%" height={120}>
|
||||
<AreaChart data={points}>
|
||||
<ResponsiveContainer width="100%" height={CHART_DEFAULTS.height}>
|
||||
<AreaChart data={points} margin={CHART_DEFAULTS.margin}>
|
||||
<CartesianGrid stroke={CHART_THEME.grid} {...CHART_DEFAULTS.grid} />
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tick={{ fontSize: 9, fill: '#a5adcb' }}
|
||||
axisLine={false}
|
||||
tick={{ fontSize: CHART_DEFAULTS.tickFontSize, fill: CHART_THEME.tick }}
|
||||
axisLine={{ stroke: CHART_THEME.axisLine }}
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 9, fill: '#a5adcb' }}
|
||||
axisLine={false}
|
||||
tick={{ fontSize: CHART_DEFAULTS.tickFontSize, fill: CHART_THEME.tick }}
|
||||
axisLine={{ stroke: CHART_THEME.axisLine }}
|
||||
tickLine={false}
|
||||
width={28}
|
||||
width={32}
|
||||
/>
|
||||
<Tooltip contentStyle={tooltipStyle} />
|
||||
<Tooltip contentStyle={TOOLTIP_CONTENT_STYLE} />
|
||||
{seriesKeys.map((key, i) => (
|
||||
<Area
|
||||
key={key}
|
||||
|
||||
@@ -6,8 +6,10 @@ import {
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
CartesianGrid,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts';
|
||||
import { CHART_DEFAULTS, CHART_THEME, TOOLTIP_CONTENT_STYLE } from '../../lib/chart-theme';
|
||||
|
||||
interface TrendChartProps {
|
||||
title: string;
|
||||
@@ -19,35 +21,29 @@ interface TrendChartProps {
|
||||
}
|
||||
|
||||
export function TrendChart({ title, data, color, type, formatter, onBarClick }: TrendChartProps) {
|
||||
const tooltipStyle = {
|
||||
background: '#363a4f',
|
||||
border: '1px solid #494d64',
|
||||
borderRadius: 6,
|
||||
color: '#cad3f5',
|
||||
fontSize: 12,
|
||||
};
|
||||
|
||||
const formatValue = (v: number) => (formatter ? [formatter(v), title] : [String(v), title]);
|
||||
|
||||
return (
|
||||
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
|
||||
<h3 className="text-xs font-semibold text-ctp-text mb-2">{title}</h3>
|
||||
<ResponsiveContainer width="100%" height={120}>
|
||||
<ResponsiveContainer width="100%" height={CHART_DEFAULTS.height}>
|
||||
{type === 'bar' ? (
|
||||
<BarChart data={data}>
|
||||
<BarChart data={data} margin={CHART_DEFAULTS.margin}>
|
||||
<CartesianGrid stroke={CHART_THEME.grid} {...CHART_DEFAULTS.grid} />
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tick={{ fontSize: 9, fill: '#a5adcb' }}
|
||||
axisLine={false}
|
||||
tick={{ fontSize: CHART_DEFAULTS.tickFontSize, fill: CHART_THEME.tick }}
|
||||
axisLine={{ stroke: CHART_THEME.axisLine }}
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 9, fill: '#a5adcb' }}
|
||||
axisLine={false}
|
||||
tick={{ fontSize: CHART_DEFAULTS.tickFontSize, fill: CHART_THEME.tick }}
|
||||
axisLine={{ stroke: CHART_THEME.axisLine }}
|
||||
tickLine={false}
|
||||
width={28}
|
||||
width={32}
|
||||
tickFormatter={formatter}
|
||||
/>
|
||||
<Tooltip contentStyle={tooltipStyle} formatter={formatValue} />
|
||||
<Tooltip contentStyle={TOOLTIP_CONTENT_STYLE} formatter={formatValue} />
|
||||
<Bar
|
||||
dataKey="value"
|
||||
fill={color}
|
||||
@@ -59,20 +55,22 @@ export function TrendChart({ title, data, color, type, formatter, onBarClick }:
|
||||
/>
|
||||
</BarChart>
|
||||
) : (
|
||||
<LineChart data={data}>
|
||||
<LineChart data={data} margin={CHART_DEFAULTS.margin}>
|
||||
<CartesianGrid stroke={CHART_THEME.grid} {...CHART_DEFAULTS.grid} />
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tick={{ fontSize: 9, fill: '#a5adcb' }}
|
||||
axisLine={false}
|
||||
tick={{ fontSize: CHART_DEFAULTS.tickFontSize, fill: CHART_THEME.tick }}
|
||||
axisLine={{ stroke: CHART_THEME.axisLine }}
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 9, fill: '#a5adcb' }}
|
||||
axisLine={false}
|
||||
tick={{ fontSize: CHART_DEFAULTS.tickFontSize, fill: CHART_THEME.tick }}
|
||||
axisLine={{ stroke: CHART_THEME.axisLine }}
|
||||
tickLine={false}
|
||||
width={28}
|
||||
width={32}
|
||||
tickFormatter={formatter}
|
||||
/>
|
||||
<Tooltip contentStyle={tooltipStyle} formatter={formatValue} />
|
||||
<Tooltip contentStyle={TOOLTIP_CONTENT_STYLE} formatter={formatValue} />
|
||||
<Line dataKey="value" stroke={color} strokeWidth={2} dot={false} />
|
||||
</LineChart>
|
||||
)}
|
||||
|
||||
19
stats/src/components/trends/TrendsTab.test.tsx
Normal file
19
stats/src/components/trends/TrendsTab.test.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { AnimeVisibilityFilter } from './TrendsTab';
|
||||
|
||||
test('AnimeVisibilityFilter uses title visibility wording', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<AnimeVisibilityFilter
|
||||
animeTitles={['KonoSuba']}
|
||||
hiddenAnime={new Set()}
|
||||
onShowAll={() => {}}
|
||||
onHideAll={() => {}}
|
||||
onToggleAnime={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
assert.match(markup, /Title Visibility/);
|
||||
assert.doesNotMatch(markup, /Anime Visibility/);
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
filterHiddenAnimeData,
|
||||
pruneHiddenAnime,
|
||||
} from './anime-visibility';
|
||||
import { LibrarySummarySection } from './LibrarySummarySection';
|
||||
|
||||
function SectionHeader({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
@@ -28,7 +29,7 @@ interface AnimeVisibilityFilterProps {
|
||||
onToggleAnime: (title: string) => void;
|
||||
}
|
||||
|
||||
function AnimeVisibilityFilter({
|
||||
export function AnimeVisibilityFilter({
|
||||
animeTitles,
|
||||
hiddenAnime,
|
||||
onShowAll,
|
||||
@@ -44,7 +45,7 @@ function AnimeVisibilityFilter({
|
||||
<div className="mb-2 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold uppercase tracking-widest text-ctp-subtext0">
|
||||
Anime Visibility
|
||||
Title Visibility
|
||||
</h4>
|
||||
<p className="mt-1 text-xs text-ctp-overlay1">
|
||||
Shared across all anime trend charts. Default: show everything.
|
||||
@@ -114,11 +115,6 @@ export function TrendsTab() {
|
||||
if (!data) return null;
|
||||
|
||||
const animeTitles = buildAnimeVisibilityOptions([
|
||||
data.animePerDay.episodes,
|
||||
data.animePerDay.watchTime,
|
||||
data.animePerDay.cards,
|
||||
data.animePerDay.words,
|
||||
data.animePerDay.lookups,
|
||||
data.animeCumulative.episodes,
|
||||
data.animeCumulative.cards,
|
||||
data.animeCumulative.words,
|
||||
@@ -126,24 +122,6 @@ export function TrendsTab() {
|
||||
]);
|
||||
const activeHiddenAnime = pruneHiddenAnime(hiddenAnime, animeTitles);
|
||||
|
||||
const filteredEpisodesPerAnime = filterHiddenAnimeData(
|
||||
data.animePerDay.episodes,
|
||||
activeHiddenAnime,
|
||||
);
|
||||
const filteredWatchTimePerAnime = filterHiddenAnimeData(
|
||||
data.animePerDay.watchTime,
|
||||
activeHiddenAnime,
|
||||
);
|
||||
const filteredCardsPerAnime = filterHiddenAnimeData(data.animePerDay.cards, activeHiddenAnime);
|
||||
const filteredWordsPerAnime = filterHiddenAnimeData(data.animePerDay.words, activeHiddenAnime);
|
||||
const filteredLookupsPerAnime = filterHiddenAnimeData(
|
||||
data.animePerDay.lookups,
|
||||
activeHiddenAnime,
|
||||
);
|
||||
const filteredLookupsPerHundredPerAnime = filterHiddenAnimeData(
|
||||
data.animePerDay.lookupsPerHundred,
|
||||
activeHiddenAnime,
|
||||
);
|
||||
const filteredAnimeProgress = filterHiddenAnimeData(
|
||||
data.animeCumulative.episodes,
|
||||
activeHiddenAnime,
|
||||
@@ -185,6 +163,18 @@ export function TrendsTab() {
|
||||
/>
|
||||
<TrendChart title="Words Seen" data={data.activity.words} color="#8bd5ca" type="bar" />
|
||||
<TrendChart title="Sessions" data={data.activity.sessions} color="#b7bdf8" type="bar" />
|
||||
<TrendChart
|
||||
title="Watch Time by Day of Week (min)"
|
||||
data={data.patterns.watchTimeByDayOfWeek}
|
||||
color="#8aadf4"
|
||||
type="bar"
|
||||
/>
|
||||
<TrendChart
|
||||
title="Watch Time by Hour (min)"
|
||||
data={data.patterns.watchTimeByHour}
|
||||
color="#c6a0f6"
|
||||
type="bar"
|
||||
/>
|
||||
|
||||
<SectionHeader>Period Trends</SectionHeader>
|
||||
<TrendChart
|
||||
@@ -221,7 +211,7 @@ export function TrendsTab() {
|
||||
type="line"
|
||||
/>
|
||||
|
||||
<SectionHeader>Anime — Per Day</SectionHeader>
|
||||
<SectionHeader>Library — Cumulative</SectionHeader>
|
||||
<AnimeVisibilityFilter
|
||||
animeTitles={animeTitles}
|
||||
hiddenAnime={activeHiddenAnime}
|
||||
@@ -239,21 +229,6 @@ export function TrendsTab() {
|
||||
})
|
||||
}
|
||||
/>
|
||||
<StackedTrendChart title="Episodes per Anime" data={filteredEpisodesPerAnime} />
|
||||
<StackedTrendChart title="Watch Time per Anime (min)" data={filteredWatchTimePerAnime} />
|
||||
<StackedTrendChart
|
||||
title="Cards Mined per Anime"
|
||||
data={filteredCardsPerAnime}
|
||||
colorPalette={cardsMinedStackedColors}
|
||||
/>
|
||||
<StackedTrendChart title="Words Seen per Anime" data={filteredWordsPerAnime} />
|
||||
<StackedTrendChart title="Lookups per Anime" data={filteredLookupsPerAnime} />
|
||||
<StackedTrendChart
|
||||
title="Lookups/100w per Anime"
|
||||
data={filteredLookupsPerHundredPerAnime}
|
||||
/>
|
||||
|
||||
<SectionHeader>Anime — Cumulative</SectionHeader>
|
||||
<StackedTrendChart title="Watch Time Progress (min)" data={filteredWatchTimeProgress} />
|
||||
<StackedTrendChart title="Episodes Progress" data={filteredAnimeProgress} />
|
||||
<StackedTrendChart
|
||||
@@ -263,19 +238,8 @@ export function TrendsTab() {
|
||||
/>
|
||||
<StackedTrendChart title="Words Seen Progress" data={filteredWordsProgress} />
|
||||
|
||||
<SectionHeader>Patterns</SectionHeader>
|
||||
<TrendChart
|
||||
title="Watch Time by Day of Week (min)"
|
||||
data={data.patterns.watchTimeByDayOfWeek}
|
||||
color="#8aadf4"
|
||||
type="bar"
|
||||
/>
|
||||
<TrendChart
|
||||
title="Watch Time by Hour (min)"
|
||||
data={data.patterns.watchTimeByHour}
|
||||
color="#c6a0f6"
|
||||
type="bar"
|
||||
/>
|
||||
<SectionHeader>Library — Summary</SectionHeader>
|
||||
<LibrarySummarySection rows={data.librarySummary} hiddenTitles={activeHiddenAnime} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -72,7 +72,7 @@ export function CrossAnimeWordsTable({
|
||||
>
|
||||
{'\u25B6'}
|
||||
</span>
|
||||
Words In Multiple Anime
|
||||
Words Across Multiple Titles
|
||||
</button>
|
||||
<div className="flex items-center gap-3">
|
||||
{hasKnownData && (
|
||||
@@ -97,8 +97,8 @@ export function CrossAnimeWordsTable({
|
||||
{collapsed ? null : ranked.length === 0 ? (
|
||||
<div className="text-xs text-ctp-overlay2 mt-3">
|
||||
{hideKnown
|
||||
? 'All multi-anime words are already known!'
|
||||
: 'No words found across multiple anime.'}
|
||||
? 'All words that span multiple titles are already known!'
|
||||
: 'No words found across multiple titles.'}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
@@ -109,7 +109,7 @@ export function CrossAnimeWordsTable({
|
||||
<th className="text-left py-2 pr-3 font-medium">Word</th>
|
||||
<th className="text-left py-2 pr-3 font-medium">Reading</th>
|
||||
<th className="text-left py-2 pr-3 font-medium w-20">POS</th>
|
||||
<th className="text-right py-2 pr-3 font-medium w-16">Anime</th>
|
||||
<th className="text-right py-2 pr-3 font-medium w-16">Titles</th>
|
||||
<th className="text-right py-2 font-medium w-16">Seen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
43
stats/src/components/vocabulary/FrequencyRankTable.test.tsx
Normal file
43
stats/src/components/vocabulary/FrequencyRankTable.test.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { FrequencyRankTable } from './FrequencyRankTable';
|
||||
import type { VocabularyEntry } from '../../types/stats';
|
||||
|
||||
function makeEntry(over: Partial<VocabularyEntry>): VocabularyEntry {
|
||||
return {
|
||||
wordId: 1,
|
||||
headword: '日本語',
|
||||
word: '日本語',
|
||||
reading: 'にほんご',
|
||||
frequency: 5,
|
||||
frequencyRank: 100,
|
||||
animeCount: 1,
|
||||
partOfSpeech: null,
|
||||
firstSeen: 0,
|
||||
lastSeen: 0,
|
||||
...over,
|
||||
} as VocabularyEntry;
|
||||
}
|
||||
|
||||
test('renders headword and reading inline in a single column (no separate Reading header)', () => {
|
||||
const entry = makeEntry({});
|
||||
const markup = renderToStaticMarkup(
|
||||
<FrequencyRankTable words={[entry]} knownWords={new Set()} />,
|
||||
);
|
||||
assert.ok(!markup.includes('>Reading<'), 'should not have a Reading column header');
|
||||
assert.ok(markup.includes('日本語'), 'should include the headword');
|
||||
assert.ok(markup.includes('にほんご'), 'should include the reading inline');
|
||||
});
|
||||
|
||||
test('omits reading when reading equals headword', () => {
|
||||
const entry = makeEntry({ headword: 'カレー', word: 'カレー', reading: 'カレー' });
|
||||
const markup = renderToStaticMarkup(
|
||||
<FrequencyRankTable words={[entry]} knownWords={new Set()} />,
|
||||
);
|
||||
assert.ok(markup.includes('カレー'), 'should include the headword');
|
||||
assert.ok(
|
||||
!markup.includes('【'),
|
||||
'should not render any bracketed reading when equal to headword',
|
||||
);
|
||||
});
|
||||
@@ -113,7 +113,6 @@ export function FrequencyRankTable({ words, knownWords, onSelectWord }: Frequenc
|
||||
<tr className="text-xs text-ctp-overlay2 border-b border-ctp-surface1">
|
||||
<th className="text-left py-2 pr-3 font-medium w-16">Rank</th>
|
||||
<th className="text-left py-2 pr-3 font-medium">Word</th>
|
||||
<th className="text-left py-2 pr-3 font-medium">Reading</th>
|
||||
<th className="text-left py-2 pr-3 font-medium w-20">POS</th>
|
||||
<th className="text-right py-2 font-medium w-20">Seen</th>
|
||||
</tr>
|
||||
@@ -128,9 +127,19 @@ export function FrequencyRankTable({ words, knownWords, onSelectWord }: Frequenc
|
||||
<td className="py-1.5 pr-3 font-mono tabular-nums text-ctp-peach text-xs">
|
||||
#{w.frequencyRank!.toLocaleString()}
|
||||
</td>
|
||||
<td className="py-1.5 pr-3 text-ctp-text font-medium">{w.headword}</td>
|
||||
<td className="py-1.5 pr-3 text-ctp-subtext0">
|
||||
{fullReading(w.headword, w.reading) || w.headword}
|
||||
<td className="py-1.5 pr-3">
|
||||
<span className="text-ctp-text font-medium">{w.headword}</span>
|
||||
{(() => {
|
||||
const reading = fullReading(w.headword, w.reading);
|
||||
// `fullReading` normalizes katakana to hiragana, so we normalize the
|
||||
// headword the same way before comparing — otherwise katakana-only
|
||||
// entries like `カレー` would render `【かれー】`.
|
||||
const normalizedHeadword = fullReading(w.headword, w.headword);
|
||||
if (!reading || reading === normalizedHeadword) return null;
|
||||
return (
|
||||
<span className="text-ctp-subtext0 text-xs ml-1.5">【{reading}】</span>
|
||||
);
|
||||
})()}
|
||||
</td>
|
||||
<td className="py-1.5 pr-3">
|
||||
{w.partOfSpeech && <PosBadge pos={w.partOfSpeech} />}
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import type { MediaLibraryItem } from '../types/stats';
|
||||
import { shouldRefreshMediaLibraryRows } from './useMediaLibrary';
|
||||
|
||||
const baseItem: MediaLibraryItem = {
|
||||
videoId: 1,
|
||||
canonicalTitle: 'watch?v=abc123',
|
||||
totalSessions: 1,
|
||||
totalActiveMs: 60_000,
|
||||
totalCards: 0,
|
||||
totalTokensSeen: 10,
|
||||
lastWatchedMs: 1_000,
|
||||
hasCoverArt: 0,
|
||||
youtubeVideoId: 'abc123',
|
||||
videoUrl: 'https://www.youtube.com/watch?v=abc123',
|
||||
videoTitle: null,
|
||||
videoThumbnailUrl: 'https://i.ytimg.com/vi/abc123/hqdefault.jpg',
|
||||
channelId: null,
|
||||
channelName: null,
|
||||
channelUrl: null,
|
||||
channelThumbnailUrl: null,
|
||||
uploaderId: null,
|
||||
uploaderUrl: null,
|
||||
description: null,
|
||||
};
|
||||
|
||||
test('shouldRefreshMediaLibraryRows requests a follow-up fetch for incomplete youtube metadata', () => {
|
||||
assert.equal(shouldRefreshMediaLibraryRows([baseItem]), true);
|
||||
});
|
||||
|
||||
test('shouldRefreshMediaLibraryRows skips follow-up fetch when youtube metadata is complete', () => {
|
||||
assert.equal(
|
||||
shouldRefreshMediaLibraryRows([
|
||||
{
|
||||
...baseItem,
|
||||
videoTitle: 'Video Name',
|
||||
channelName: 'Creator Name',
|
||||
channelThumbnailUrl: 'https://yt3.googleusercontent.com/channel-avatar=s88',
|
||||
},
|
||||
]),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('shouldRefreshMediaLibraryRows ignores non-youtube rows', () => {
|
||||
assert.equal(
|
||||
shouldRefreshMediaLibraryRows([
|
||||
{
|
||||
...baseItem,
|
||||
youtubeVideoId: null,
|
||||
videoUrl: null,
|
||||
},
|
||||
]),
|
||||
false,
|
||||
);
|
||||
});
|
||||
@@ -1,65 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { getStatsClient } from './useStatsApi';
|
||||
import type { MediaLibraryItem } from '../types/stats';
|
||||
|
||||
const MEDIA_LIBRARY_REFRESH_DELAY_MS = 1_500;
|
||||
const MEDIA_LIBRARY_MAX_RETRIES = 3;
|
||||
|
||||
export function shouldRefreshMediaLibraryRows(rows: MediaLibraryItem[]): boolean {
|
||||
return rows.some((row) => {
|
||||
if (!row.youtubeVideoId) {
|
||||
return false;
|
||||
}
|
||||
return !row.videoTitle?.trim() || !row.channelName?.trim() || !row.channelThumbnailUrl?.trim();
|
||||
});
|
||||
}
|
||||
|
||||
export function useMediaLibrary() {
|
||||
const [media, setMedia] = useState<MediaLibraryItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let retryCount = 0;
|
||||
let retryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const load = (isInitial = false) => {
|
||||
if (isInitial) {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
}
|
||||
getStatsClient()
|
||||
.getMediaLibrary()
|
||||
.then((rows) => {
|
||||
if (cancelled) return;
|
||||
setMedia(rows);
|
||||
if (shouldRefreshMediaLibraryRows(rows) && retryCount < MEDIA_LIBRARY_MAX_RETRIES) {
|
||||
retryCount += 1;
|
||||
retryTimer = setTimeout(() => {
|
||||
retryTimer = null;
|
||||
load(false);
|
||||
}, MEDIA_LIBRARY_REFRESH_DELAY_MS);
|
||||
}
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
if (cancelled) return;
|
||||
setError(err.message);
|
||||
})
|
||||
.finally(() => {
|
||||
if (cancelled || !isInitial) return;
|
||||
setLoading(false);
|
||||
});
|
||||
};
|
||||
|
||||
load(true);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (retryTimer) {
|
||||
clearTimeout(retryTimer);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { media, loading, error };
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { useState, useEffect } from 'react';
|
||||
import { getStatsClient } from './useStatsApi';
|
||||
import type { TrendsDashboardData } from '../types/stats';
|
||||
|
||||
export type TimeRange = '7d' | '30d' | '90d' | 'all';
|
||||
export type TimeRange = '7d' | '30d' | '90d' | '365d' | 'all';
|
||||
export type GroupBy = 'day' | 'month';
|
||||
|
||||
export function useTrends(range: TimeRange, groupBy: GroupBy) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -288,6 +288,19 @@ export interface TrendPerAnimePoint {
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface LibrarySummaryRow {
|
||||
title: string;
|
||||
watchTimeMin: number;
|
||||
videos: number;
|
||||
sessions: number;
|
||||
cards: number;
|
||||
words: number;
|
||||
lookups: number;
|
||||
lookupsPerHundred: number | null;
|
||||
firstWatched: number;
|
||||
lastWatched: number;
|
||||
}
|
||||
|
||||
export interface TrendsDashboardData {
|
||||
activity: {
|
||||
watchTime: TrendChartPoint[];
|
||||
@@ -307,14 +320,7 @@ export interface TrendsDashboardData {
|
||||
ratios: {
|
||||
lookupsPerHundred: TrendChartPoint[];
|
||||
};
|
||||
animePerDay: {
|
||||
episodes: TrendPerAnimePoint[];
|
||||
watchTime: TrendPerAnimePoint[];
|
||||
cards: TrendPerAnimePoint[];
|
||||
words: TrendPerAnimePoint[];
|
||||
lookups: TrendPerAnimePoint[];
|
||||
lookupsPerHundred: TrendPerAnimePoint[];
|
||||
};
|
||||
librarySummary: LibrarySummaryRow[];
|
||||
animeCumulative: {
|
||||
watchTime: TrendPerAnimePoint[];
|
||||
episodes: TrendPerAnimePoint[];
|
||||
|
||||
Reference in New Issue
Block a user