mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-04-09 04:19:27 -07:00
feat(stats): roll up same-episode sessions within a day
Group sessions for the same video inside each day header so repeated viewings of an episode collapse into a single expandable bucket with aggregate active time and card counts. Multi-session buckets get a dedicated delete action that wipes every session in the group via the bulk delete endpoint; singleton buckets continue to render the existing SessionRow flow unchanged. Delete confirmation copy lives in delete-confirm for parity with the other bulk-delete flows, and the bucket delete path is extracted into a pure buildBucketDeleteHandler factory so it can be unit-tested without rendering the tab. MediaSessionList is intentionally untouched -- it is already scoped to a single video and doesn't need rollup.
This commit is contained in:
156
stats/src/components/sessions/SessionsTab.test.tsx
Normal file
156
stats/src/components/sessions/SessionsTab.test.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
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,7 +178,9 @@ export function SessionsTab({
|
||||
|
||||
{deleteError ? <div className="text-sm text-ctp-red">{deleteError}</div> : null}
|
||||
|
||||
{Array.from(groups.entries()).map(([dayLabel, daySessions]) => (
|
||||
{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">
|
||||
@@ -119,7 +189,78 @@ export function SessionsTab({
|
||||
<div className="flex-1 h-px bg-gradient-to-r from-ctp-surface1 to-transparent" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{daySessions.map((s) => {
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
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}>
|
||||
@@ -127,7 +268,11 @@ export function SessionsTab({
|
||||
session={s}
|
||||
isExpanded={expandedId === s.sessionId}
|
||||
detailsId={detailsId}
|
||||
onToggle={() => setExpandedId(expandedId === s.sessionId ? null : s.sessionId)}
|
||||
onToggle={() =>
|
||||
setExpandedId(
|
||||
expandedId === s.sessionId ? null : s.sessionId,
|
||||
)
|
||||
}
|
||||
onDelete={() => void handleDeleteSession(s)}
|
||||
deleteDisabled={deletingSessionId === s.sessionId}
|
||||
onNavigateToMediaDetail={onNavigateToMediaDetail}
|
||||
@@ -141,8 +286,14 @@ export function SessionsTab({
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{filtered.length === 0 && (
|
||||
<div className="text-ctp-overlay2 text-sm">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
confirmBucketDelete,
|
||||
confirmDayGroupDelete,
|
||||
confirmEpisodeDelete,
|
||||
confirmSessionDelete,
|
||||
@@ -54,6 +55,41 @@ 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.equal(calls.length, 1);
|
||||
assert.match(calls[0]!, /3/);
|
||||
assert.match(calls[0]!, /My Episode/);
|
||||
assert.match(calls[0]!, /sessions/);
|
||||
} finally {
|
||||
globalThis.confirm = originalConfirm;
|
||||
}
|
||||
});
|
||||
|
||||
test('confirmBucketDelete uses singular 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.match(calls[0]!, /1 session of/);
|
||||
} 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,9 @@ 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 {
|
||||
return globalThis.confirm(
|
||||
`Delete all ${count} session${count === 1 ? '' : 's'} of "${title}" from this day?`,
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user