import { useEffect, useMemo, useState } from 'react'; import { useSessions } from '../../hooks/useSessions'; import { SessionRow } from './SessionRow'; import { SessionDetail } from './SessionDetail'; import { apiClient } from '../../lib/api-client'; 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 { const groups = new Map(); for (const session of sessions) { const dayLabel = formatSessionDayLabel(session.startedAtMs); const group = groups.get(dayLabel); if (group) { group.push(session); } else { groups.set(dayLabel, [session]); } } return groups; } export interface BucketDeleteDeps { bucket: SessionBucket; apiClient: { deleteSessions: (ids: number[]) => 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; onNavigateToMediaDetail?: (videoId: number) => void; } export function SessionsTab({ initialSessionId, onClearInitialSession, onNavigateToMediaDetail, }: 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); }, [sessions]); useEffect(() => { if (initialSessionId != null && sessions.length > 0) { let canceled = false; setExpandedId(initialSessionId); onClearInitialSession?.(); const frame = requestAnimationFrame(() => { if (canceled) return; const el = document.getElementById(`session-details-${initialSessionId}`); if (el) { el.scrollIntoView({ behavior: 'smooth', block: 'start' }); } else { // Session row itself if detail hasn't rendered yet const row = document.querySelector( `[aria-controls="session-details-${initialSessionId}"]`, ); row?.scrollIntoView({ behavior: 'smooth', block: 'start' }); } }); return () => { canceled = true; cancelAnimationFrame(frame); }; } }, [initialSessionId, sessions, onClearInitialSession]); const filtered = useMemo(() => { const q = search.trim().toLowerCase(); if (!q) return visibleSessions; return visibleSessions.filter((s) => s.canonicalTitle?.toLowerCase().includes(q)); }, [visibleSessions, search]); 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; setDeleteError(null); setDeletingSessionId(session.sessionId); try { await apiClient.deleteSession(session.sessionId); setVisibleSessions((prev) => prev.filter((item) => item.sessionId !== session.sessionId)); setExpandedId((prev) => (prev === session.sessionId ? null : prev)); } catch (err) { setDeleteError(err instanceof Error ? err.message : 'Failed to delete session.'); } finally { setDeletingSessionId(null); } }; 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}
; return (
setSearch(e.target.value)} className="w-full 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" /> {deleteError ?
{deleteError}
: null} {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 && (
{search.trim() ? 'No sessions matching your search.' : 'No sessions recorded yet.'}
)}
); }