Files
SubMiner/stats/src/components/sessions/SessionsTab.tsx
T

349 lines
14 KiB
TypeScript

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<string, SessionSummary[]> {
const groups = new Map<string, SessionSummary[]>();
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<void> };
confirm: (title: string, count: number) => boolean | Promise<boolean>;
/** Called once confirmation passes, just before the delete request begins. */
onStart?: (sessionIds: number[]) => void;
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, onStart, onSuccess, onError } = deps;
return async () => {
const title = bucket.representativeSession.canonicalTitle ?? 'this episode';
const ids = bucket.sessions.map((s) => s.sessionId);
try {
if (!(await confirm(title, ids.length))) return;
onStart?.(ids);
await client.deleteSessions(ids);
onSuccess(ids);
} catch (err) {
onError(err instanceof Error ? err.message : 'Failed to delete sessions.');
}
};
}
export function incrementDeletingSessionCounts(
prev: ReadonlyMap<number, number>,
ids: number[],
): Map<number, number> {
const next = new Map(prev);
for (const id of ids) next.set(id, (next.get(id) ?? 0) + 1);
return next;
}
export function decrementDeletingSessionCounts(
prev: ReadonlyMap<number, number>,
ids: number[],
): Map<number, number> {
const next = new Map(prev);
for (const id of ids) {
const count = next.get(id);
if (!count) continue;
if (count <= 1) next.delete(id);
else next.set(id, count - 1);
}
return next;
}
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<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 [deletingSessionIds, setDeletingSessionIds] = useState<Map<number, number>>(
() => new Map(),
);
const [deletingBucketKey, setDeletingBucketKey] = useState<string | null>(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 markDeleting = (ids: number[]) => {
setDeletingSessionIds((prev) => incrementDeletingSessionCounts(prev, ids));
};
const unmarkDeleting = (ids: number[]) => {
setDeletingSessionIds((prev) => decrementDeletingSessionCounts(prev, ids));
};
const handleDeleteSession = async (session: SessionSummary) => {
let confirmed = false;
try {
confirmed = await confirmSessionDelete();
} catch (err) {
setDeleteError(err instanceof Error ? err.message : 'Failed to confirm delete.');
return;
}
if (!confirmed) return;
setDeleteError(null);
markDeleting([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 {
unmarkDeleting([session.sessionId]);
}
};
const handleDeleteBucket = async (bucket: SessionBucket) => {
setDeleteError(null);
setDeletingBucketKey(bucket.key);
const handler = buildBucketDeleteHandler({
bucket,
apiClient,
confirm: confirmBucketDelete,
onStart: (ids) => markDeleting(ids),
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);
unmarkDeleting(bucket.sessions.map((session) => session.sessionId));
}
};
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">
<input
type="search"
aria-label="Search sessions by title"
placeholder="Search by title..."
value={search}
onChange={(e) => 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 ? <div className="text-sm text-ctp-red">{deleteError}</div> : null}
{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={deletingSessionIds.has(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}>
<SessionRow
session={s}
isExpanded={expandedId === s.sessionId}
detailsId={detailsId}
onToggle={() =>
setExpandedId(expandedId === s.sessionId ? null : s.sessionId)
}
onDelete={() => void handleDeleteSession(s)}
deleteDisabled={deletingSessionIds.has(s.sessionId)}
onNavigateToMediaDetail={onNavigateToMediaDetail}
/>
{expandedId === s.sessionId && (
<div id={detailsId}>
<SessionDetail session={s} />
</div>
)}
</div>
);
})}
</div>
)}
</div>
);
})}
</div>
</div>
);
})}
{filtered.length === 0 && (
<div className="text-ctp-overlay2 text-sm">
{search.trim() ? 'No sessions matching your search.' : 'No sessions recorded yet.'}
</div>
)}
</div>
);
}