From b4aea0f77e7eadb46a8419cbdbb71bb074e3e33c Mon Sep 17 00:00:00 2001 From: sudacode Date: Thu, 9 Apr 2026 01:19:30 -0700 Subject: [PATCH] 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. --- .../components/sessions/SessionsTab.test.tsx | 156 +++++++++++++ stats/src/components/sessions/SessionsTab.tsx | 219 +++++++++++++++--- stats/src/lib/delete-confirm.test.ts | 36 +++ stats/src/lib/delete-confirm.ts | 6 + 4 files changed, 383 insertions(+), 34 deletions(-) create mode 100644 stats/src/components/sessions/SessionsTab.test.tsx diff --git a/stats/src/components/sessions/SessionsTab.test.tsx b/stats/src/components/sessions/SessionsTab.test.tsx new file mode 100644 index 00000000..a91020e8 --- /dev/null +++ b/stats/src/components/sessions/SessionsTab.test.tsx @@ -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 { + 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'); +}); diff --git a/stats/src/components/sessions/SessionsTab.tsx b/stats/src/components/sessions/SessionsTab.tsx index 3975245b..560c0d4d 100644 --- a/stats/src/components/sessions/SessionsTab.tsx +++ b/stats/src/components/sessions/SessionsTab.tsx @@ -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 { @@ -23,6 +24,35 @@ function groupSessionsByDay(sessions: SessionSummary[]): Map Promise }; + 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 { + 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(null); + const [expandedBuckets, setExpandedBuckets] = useState>(() => new Set()); const [search, setSearch] = useState(''); const [visibleSessions, setVisibleSessions] = useState([]); const [deleteError, setDeleteError] = useState(null); const [deletingSessionId, setDeletingSessionId] = useState(null); + const [deletingBucketKey, setDeletingBucketKey] = useState(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
Loading...
; if (error) return
Error: {error}
; @@ -110,39 +178,122 @@ export function SessionsTab({ {deleteError ?
{deleteError}
: null} - {Array.from(groups.entries()).map(([dayLabel, daySessions]) => ( -
-
-

- {dayLabel} -

-
-
-
- {daySessions.map((s) => { - const detailsId = `session-details-${s.sessionId}`; - return ( -
- setExpandedId(expandedId === s.sessionId ? null : s.sessionId)} - onDelete={() => void handleDeleteSession(s)} - deleteDisabled={deletingSessionId === s.sessionId} - onNavigateToMediaDetail={onNavigateToMediaDetail} - /> - {expandedId === s.sessionId && ( -
- + {Array.from(dayGroups.entries()).map(([dayLabel, daySessions]) => { + const buckets = groupSessionsByVideo(daySessions); + return ( +
+
+

+ {dayLabel} +

+
+
+
+ {buckets.map((bucket) => { + if (bucket.sessions.length === 1) { + const s = bucket.sessions[0]!; + const detailsId = `session-details-${s.sessionId}`; + return ( +
+ + setExpandedId(expandedId === s.sessionId ? null : s.sessionId) + } + onDelete={() => void handleDeleteSession(s)} + deleteDisabled={deletingSessionId === s.sessionId} + onNavigateToMediaDetail={onNavigateToMediaDetail} + /> + {expandedId === s.sessionId && ( +
+ +
+ )}
- )} -
- ); - })} + ); + } + + 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 ( +
+
+ + +
+ {isExpanded && ( +
+ {bucket.sessions.map((s) => { + const detailsId = `session-details-${s.sessionId}`; + return ( +
+ + setExpandedId( + expandedId === s.sessionId ? null : s.sessionId, + ) + } + onDelete={() => void handleDeleteSession(s)} + deleteDisabled={deletingSessionId === s.sessionId} + onNavigateToMediaDetail={onNavigateToMediaDetail} + /> + {expandedId === s.sessionId && ( +
+ +
+ )} +
+ ); + })} +
+ )} +
+ ); + })} +
-
- ))} + ); + })} {filtered.length === 0 && (
diff --git a/stats/src/lib/delete-confirm.test.ts b/stats/src/lib/delete-confirm.test.ts index 35889daf..044de2b7 100644 --- a/stats/src/lib/delete-confirm.test.ts +++ b/stats/src/lib/delete-confirm.test.ts @@ -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; diff --git a/stats/src/lib/delete-confirm.ts b/stats/src/lib/delete-confirm.ts index b3f7cd31..14f7abf3 100644 --- a/stats/src/lib/delete-confirm.ts +++ b/stats/src/lib/delete-confirm.ts @@ -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?`, + ); +}