mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-20 03:16:46 -07:00
- Route default `subminer stats` through attached `--stats`; keep daemon path for `--background`/`--stop` - Update overview metrics: lookup rate uses lifetime Yomitan lookups per 100 tokens; new words dedupe by headword - Suppress repeated macOS `Overlay loading...` OSD during fullscreen tracker flaps and improve session-detail chart scaling - Add/adjust launcher, tracker query, stats server, IPC, overlay, and stats UI regression tests; add changelog fragments
159 lines
5.4 KiB
TypeScript
159 lines
5.4 KiB
TypeScript
import { useState, useEffect } from 'react';
|
|
import { useOverview } from '../../hooks/useOverview';
|
|
import { useStreakCalendar } from '../../hooks/useStreakCalendar';
|
|
import { HeroStats } from './HeroStats';
|
|
import { StreakCalendar } from './StreakCalendar';
|
|
import { RecentSessions } from './RecentSessions';
|
|
import { TrackingSnapshot } from './TrackingSnapshot';
|
|
import { TrendChart } from '../trends/TrendChart';
|
|
import { buildOverviewSummary, buildStreakCalendar } from '../../lib/dashboard-data';
|
|
import { apiClient } from '../../lib/api-client';
|
|
import { getStatsClient } from '../../hooks/useStatsApi';
|
|
import {
|
|
confirmSessionDelete,
|
|
confirmDayGroupDelete,
|
|
confirmAnimeGroupDelete,
|
|
} from '../../lib/delete-confirm';
|
|
import type { SessionSummary } from '../../types/stats';
|
|
|
|
interface OverviewTabProps {
|
|
onNavigateToMediaDetail: (videoId: number, sessionId?: number | null) => void;
|
|
onNavigateToSession: (sessionId: number) => void;
|
|
}
|
|
|
|
export function OverviewTab({ onNavigateToMediaDetail, onNavigateToSession }: OverviewTabProps) {
|
|
const { data, sessions, setSessions, loading, error } = useOverview();
|
|
const { calendar, loading: calLoading } = useStreakCalendar(90);
|
|
const [deleteError, setDeleteError] = useState<string | null>(null);
|
|
const [deletingIds, setDeletingIds] = useState<Set<number>>(new Set());
|
|
const [knownWordsSummary, setKnownWordsSummary] = useState<{
|
|
totalUniqueWords: number;
|
|
knownWordCount: number;
|
|
} | null>(null);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
getStatsClient()
|
|
.getKnownWordsSummary()
|
|
.then((data) => {
|
|
if (!cancelled) setKnownWordsSummary(data);
|
|
})
|
|
.catch(() => {
|
|
if (!cancelled) setKnownWordsSummary(null);
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, []);
|
|
|
|
const handleDeleteSession = async (session: SessionSummary) => {
|
|
if (!confirmSessionDelete()) return;
|
|
setDeleteError(null);
|
|
setDeletingIds((prev) => new Set(prev).add(session.sessionId));
|
|
try {
|
|
await apiClient.deleteSession(session.sessionId);
|
|
setSessions((prev) => prev.filter((s) => s.sessionId !== session.sessionId));
|
|
} catch (err) {
|
|
setDeleteError(err instanceof Error ? err.message : 'Failed to delete session.');
|
|
} finally {
|
|
setDeletingIds((prev) => {
|
|
const next = new Set(prev);
|
|
next.delete(session.sessionId);
|
|
return next;
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleDeleteDayGroup = async (dayLabel: string, daySessions: SessionSummary[]) => {
|
|
if (!confirmDayGroupDelete(dayLabel, daySessions.length)) return;
|
|
setDeleteError(null);
|
|
const ids = daySessions.map((s) => s.sessionId);
|
|
setDeletingIds((prev) => {
|
|
const next = new Set(prev);
|
|
for (const id of ids) next.add(id);
|
|
return next;
|
|
});
|
|
try {
|
|
await apiClient.deleteSessions(ids);
|
|
const idSet = new Set(ids);
|
|
setSessions((prev) => prev.filter((s) => !idSet.has(s.sessionId)));
|
|
} catch (err) {
|
|
setDeleteError(err instanceof Error ? err.message : 'Failed to delete sessions.');
|
|
} finally {
|
|
setDeletingIds((prev) => {
|
|
const next = new Set(prev);
|
|
for (const id of ids) next.delete(id);
|
|
return next;
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleDeleteAnimeGroup = async (groupSessions: SessionSummary[]) => {
|
|
const title =
|
|
groupSessions[0]?.animeTitle ?? groupSessions[0]?.canonicalTitle ?? 'Unknown Media';
|
|
if (!confirmAnimeGroupDelete(title, groupSessions.length)) return;
|
|
setDeleteError(null);
|
|
const ids = groupSessions.map((s) => s.sessionId);
|
|
setDeletingIds((prev) => {
|
|
const next = new Set(prev);
|
|
for (const id of ids) next.add(id);
|
|
return next;
|
|
});
|
|
try {
|
|
await apiClient.deleteSessions(ids);
|
|
const idSet = new Set(ids);
|
|
setSessions((prev) => prev.filter((s) => !idSet.has(s.sessionId)));
|
|
} catch (err) {
|
|
setDeleteError(err instanceof Error ? err.message : 'Failed to delete sessions.');
|
|
} finally {
|
|
setDeletingIds((prev) => {
|
|
const next = new Set(prev);
|
|
for (const id of ids) next.delete(id);
|
|
return next;
|
|
});
|
|
}
|
|
};
|
|
|
|
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>;
|
|
if (!data) return null;
|
|
|
|
const summary = buildOverviewSummary(data);
|
|
const streakData = buildStreakCalendar(calendar);
|
|
const showTrackedCardNote = summary.totalTrackedCards === 0 && summary.activeDays > 0;
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<HeroStats summary={summary} sessions={sessions} />
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<TrendChart
|
|
title="Last 14 Days Watch Time (min)"
|
|
data={summary.recentWatchTime}
|
|
color="#8aadf4"
|
|
type="bar"
|
|
/>
|
|
{!calLoading && <StreakCalendar data={streakData} />}
|
|
</div>
|
|
|
|
<TrackingSnapshot
|
|
summary={summary}
|
|
showTrackedCardNote={showTrackedCardNote}
|
|
knownWordsSummary={knownWordsSummary}
|
|
/>
|
|
|
|
{deleteError ? <div className="text-sm text-ctp-red">{deleteError}</div> : null}
|
|
|
|
<RecentSessions
|
|
sessions={sessions}
|
|
onNavigateToMediaDetail={onNavigateToMediaDetail}
|
|
onNavigateToSession={onNavigateToSession}
|
|
onDeleteSession={handleDeleteSession}
|
|
onDeleteDayGroup={handleDeleteDayGroup}
|
|
onDeleteAnimeGroup={handleDeleteAnimeGroup}
|
|
deletingIds={deletingIds}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|