feat(stats): dashboard updates (#50)

This commit is contained in:
2026-04-10 02:46:50 -07:00
committed by GitHub
parent 9b4de93283
commit 05cf4a6fe5
53 changed files with 5250 additions and 660 deletions
@@ -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">
+1 -1
View File
@@ -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>
@@ -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');
});
+183 -34
View File
@@ -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">