mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-10-20 17:01:24 -07:00
fix(stats): count mined cards once and keep lexical stats exact after deletes (#280)
This commit is contained in:
1 parent
121b688afb
commit
e77aa5c3b3
24 files changed
+736
-153
No files matched your search
@@ -0,0 +1,271 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { Window } from 'happy-dom';
|
||||
import { act } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { apiClient } from '../../lib/api-client';
|
||||
import { setDeleteConfirmPresenter } from '../../lib/delete-confirm';
|
||||
import { localDayFromMs } from '../../lib/formatters';
|
||||
import type { OverviewData, SessionSummary } from '../../types/stats';
|
||||
import { OverviewTab } from './OverviewTab';
|
||||
|
||||
function installDom() {
|
||||
const window = new Window();
|
||||
const properties = {
|
||||
window,
|
||||
document: window.document,
|
||||
HTMLElement: window.HTMLElement,
|
||||
ResizeObserver: window.ResizeObserver,
|
||||
IS_REACT_ACT_ENVIRONMENT: true,
|
||||
};
|
||||
const previous = Object.keys(properties).map(
|
||||
(key) => [key, Object.getOwnPropertyDescriptor(globalThis, key)] as const,
|
||||
);
|
||||
for (const [key, value] of Object.entries(properties)) {
|
||||
Object.defineProperty(globalThis, key, { value, configurable: true, writable: true });
|
||||
}
|
||||
return () => {
|
||||
for (const [key, descriptor] of previous) {
|
||||
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
|
||||
else Reflect.deleteProperty(globalThis, key);
|
||||
}
|
||||
window.happyDOM.abort();
|
||||
};
|
||||
}
|
||||
|
||||
function session(sessionId: number): SessionSummary {
|
||||
return {
|
||||
sessionId,
|
||||
canonicalTitle: `Test episode ${sessionId}`,
|
||||
videoId: null,
|
||||
animeId: null,
|
||||
animeTitle: 'Test anime',
|
||||
startedAtMs: Date.now(),
|
||||
endedAtMs: Date.now(),
|
||||
totalWatchedMs: 60_000,
|
||||
activeWatchedMs: 60_000,
|
||||
linesSeen: 1,
|
||||
tokensSeen: 10,
|
||||
cardsMined: 2,
|
||||
lookupCount: 0,
|
||||
lookupHits: 0,
|
||||
yomitanLookupCount: 0,
|
||||
knownWordsSeen: 1,
|
||||
knownWordRate: 0.1,
|
||||
};
|
||||
}
|
||||
|
||||
function overview(sessions: SessionSummary[]): OverviewData {
|
||||
const count = sessions.length;
|
||||
return {
|
||||
sessions,
|
||||
rollups: count
|
||||
? [
|
||||
{
|
||||
rollupDayOrMonth: localDayFromMs(Date.now()),
|
||||
videoId: null,
|
||||
totalSessions: count,
|
||||
totalActiveMin: count,
|
||||
totalLinesSeen: count,
|
||||
totalTokensSeen: count * 10,
|
||||
totalCards: count * 2,
|
||||
cardsPerHour: null,
|
||||
tokensPerMin: null,
|
||||
lookupHitRate: null,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
hints: {
|
||||
totalSessions: count,
|
||||
activeSessions: 0,
|
||||
episodesToday: count,
|
||||
activeAnimeCount: count ? 1 : 0,
|
||||
totalEpisodesWatched: count,
|
||||
totalAnimeCompleted: 0,
|
||||
totalActiveMin: count,
|
||||
activeDays: count ? 1 : 0,
|
||||
totalCards: count * 2,
|
||||
totalTokensSeen: count * 10,
|
||||
totalLookupCount: 0,
|
||||
totalLookupHits: 0,
|
||||
totalYomitanLookupCount: 0,
|
||||
newWordsToday: count,
|
||||
newWordsThisWeek: count,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function metric(container: HTMLElement, label: string): string {
|
||||
const element = [...container.querySelectorAll('div')].find(
|
||||
(element) => element.textContent === label,
|
||||
);
|
||||
assert.ok(element?.parentElement, `expected metric ${label}`);
|
||||
return (element.parentElement.textContent ?? '').replace(label, '').trim();
|
||||
}
|
||||
|
||||
for (const mode of ['session', 'day', 'anime', 'failed'] as const) {
|
||||
test(`Overview refreshes all dependent data after ${mode} deletion`, async () => {
|
||||
const uninstallDom = installDom();
|
||||
const original = { ...apiClient };
|
||||
const restoreConfirm = setDeleteConfirmPresenter(() => true);
|
||||
let sessions =
|
||||
mode === 'session' || mode === 'failed'
|
||||
? [session(1)]
|
||||
: [
|
||||
{ ...session(1), animeId: 7 },
|
||||
{ ...session(2), animeId: 7 },
|
||||
];
|
||||
const reads = { overview: 0, sessions: 0, calendar: 0, words: 0 };
|
||||
const deletedIds: number[] = [];
|
||||
apiClient.getOverview = async () => {
|
||||
reads.overview++;
|
||||
return overview(sessions);
|
||||
};
|
||||
apiClient.getSessions = async () => {
|
||||
reads.sessions++;
|
||||
return sessions;
|
||||
};
|
||||
apiClient.getStreakCalendar = async () => {
|
||||
reads.calendar++;
|
||||
return [{ epochDay: localDayFromMs(Date.now()), totalActiveMin: sessions.length }];
|
||||
};
|
||||
apiClient.getKnownWordsSummary = async () => {
|
||||
reads.words++;
|
||||
return { totalUniqueWords: 10 + sessions.length, knownWordCount: 5 };
|
||||
};
|
||||
apiClient.getCoverImages = async () => ({ anime: {}, media: {} });
|
||||
async function deleteSessions(ids: number[]) {
|
||||
if (mode === 'failed') throw new Error('Deletion failed');
|
||||
deletedIds.push(...ids);
|
||||
sessions = sessions.filter((session) => !ids.includes(session.sessionId));
|
||||
}
|
||||
apiClient.deleteSession = async (id) => deleteSessions([id]);
|
||||
apiClient.deleteSessions = deleteSessions;
|
||||
const container = document.createElement('div');
|
||||
document.body.append(container);
|
||||
const root = createRoot(container);
|
||||
try {
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<OverviewTab onNavigateToMediaDetail={() => {}} onNavigateToSession={() => {}} />,
|
||||
),
|
||||
);
|
||||
const count = sessions.length;
|
||||
assert.match(metric(container, 'Cards Mined Today'), new RegExp(`^${count * 2}`));
|
||||
assert.match(metric(container, 'Known Words'), new RegExp(`/ ${10 + count}`));
|
||||
assert.equal(container.querySelectorAll('.bg-ctp-green\\/30').length, 1);
|
||||
const prefix =
|
||||
mode === 'day'
|
||||
? 'Delete all sessions from '
|
||||
: mode === 'anime'
|
||||
? 'Delete all sessions for '
|
||||
: 'Delete session ';
|
||||
const button = [...container.querySelectorAll('button')].find((button) =>
|
||||
button.getAttribute('aria-label')?.startsWith(prefix),
|
||||
);
|
||||
assert.ok(button, `expected ${mode} delete button`);
|
||||
await act(async () => button.click());
|
||||
if (mode === 'failed') {
|
||||
assert.match(container.textContent ?? '', /Deletion failed/);
|
||||
assert.match(metric(container, 'Cards Mined Today'), /^2/);
|
||||
assert.deepEqual(reads, { overview: 1, sessions: 1, calendar: 1, words: 1 });
|
||||
assert.deepEqual(deletedIds, []);
|
||||
} else {
|
||||
assert.deepEqual(deletedIds, count === 1 ? [1] : [1, 2]);
|
||||
for (const label of [
|
||||
'Cards Mined Today',
|
||||
'Episodes Today',
|
||||
'Sessions',
|
||||
'Cards Mined',
|
||||
'Words Today',
|
||||
'New Words Today',
|
||||
]) {
|
||||
assert.equal(metric(container, label), '0', `${label} should reflect the deletion`);
|
||||
}
|
||||
assert.match(metric(container, 'Known Words'), /5\s*\/ 10/);
|
||||
assert.equal(container.querySelectorAll('.bg-ctp-green\\/30').length, 0);
|
||||
assert.match(container.textContent ?? '', /No sessions yet/);
|
||||
assert.deepEqual(reads, { overview: 2, sessions: 2, calendar: 2, words: 2 });
|
||||
}
|
||||
} finally {
|
||||
await act(async () => root.unmount());
|
||||
restoreConfirm();
|
||||
Object.assign(apiClient, original);
|
||||
uninstallDom();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (const failedRead of ['overview', 'sessions', 'calendar'] as const) {
|
||||
test(`Overview stays available during a failed ${failedRead} refresh and can retry`, async () => {
|
||||
const uninstallDom = installDom();
|
||||
const original = { ...apiClient };
|
||||
const restoreConfirm = setDeleteConfirmPresenter(() => true);
|
||||
let sessions = [session(1)];
|
||||
let refreshing = false;
|
||||
let failRefresh = true;
|
||||
let rejectRefresh = (_error: Error) => {};
|
||||
const pendingRefresh = new Promise<never>((_resolve, reject) => {
|
||||
rejectRefresh = reject;
|
||||
});
|
||||
apiClient.getOverview = async () => {
|
||||
if (refreshing && failRefresh && failedRead === 'overview') return pendingRefresh;
|
||||
return overview(sessions);
|
||||
};
|
||||
apiClient.getSessions = async () => {
|
||||
if (refreshing && failRefresh && failedRead === 'sessions') return pendingRefresh;
|
||||
return sessions;
|
||||
};
|
||||
apiClient.getStreakCalendar = async () => {
|
||||
if (refreshing && failRefresh && failedRead === 'calendar') return pendingRefresh;
|
||||
return [];
|
||||
};
|
||||
apiClient.getKnownWordsSummary = async () => ({
|
||||
totalUniqueWords: 0,
|
||||
knownWordCount: 0,
|
||||
});
|
||||
apiClient.deleteSession = async () => {
|
||||
sessions = [];
|
||||
refreshing = true;
|
||||
};
|
||||
// A calendar-only failure still lets the overview totals refresh.
|
||||
const staleCards = failedRead === 'calendar' ? '0' : '2';
|
||||
const container = document.createElement('div');
|
||||
document.body.append(container);
|
||||
const root = createRoot(container);
|
||||
try {
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<OverviewTab onNavigateToMediaDetail={() => {}} onNavigateToSession={() => {}} />,
|
||||
),
|
||||
);
|
||||
const deleteButton = [...container.querySelectorAll('button')].find((button) =>
|
||||
button.getAttribute('aria-label')?.startsWith('Delete session '),
|
||||
);
|
||||
assert.ok(deleteButton);
|
||||
await act(async () => deleteButton.click());
|
||||
assert.match(container.textContent ?? '', /Tracking Snapshot/);
|
||||
assert.equal(metric(container, 'Cards Mined Today'), staleCards);
|
||||
assert.match(container.textContent ?? '', /No sessions yet/);
|
||||
|
||||
await act(async () => rejectRefresh(new Error(`${failedRead} unavailable`)));
|
||||
assert.match(container.textContent ?? '', /Tracking Snapshot/);
|
||||
assert.equal(metric(container, 'Cards Mined Today'), staleCards);
|
||||
assert.match(container.querySelector('[role="alert"]')?.textContent ?? '', /unavailable/);
|
||||
|
||||
const retryButton = [...container.querySelectorAll('button')].find(
|
||||
(button) => button.textContent === 'Retry',
|
||||
);
|
||||
assert.ok(retryButton, 'failed refresh should offer a retry');
|
||||
failRefresh = false;
|
||||
await act(async () => retryButton.click());
|
||||
assert.equal(metric(container, 'Cards Mined Today'), '0');
|
||||
assert.equal(container.querySelector('[role="alert"]'), null);
|
||||
} finally {
|
||||
await act(async () => root.unmount());
|
||||
restoreConfirm();
|
||||
Object.assign(apiClient, original);
|
||||
uninstallDom();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -27,8 +27,9 @@ export function OverviewTab({
|
||||
onNavigateToSession,
|
||||
isActive = true,
|
||||
}: OverviewTabProps) {
|
||||
const { data, sessions, setSessions, loading, error } = useOverview();
|
||||
const { calendar, loading: calLoading } = useStreakCalendar(90);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
const { data, sessions, setSessions, loading, error } = useOverview(refreshKey);
|
||||
const { calendar, loading: calLoading, error: calendarError } = useStreakCalendar(90, refreshKey);
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
const [deletingIds, setDeletingIds] = useState<Set<number>>(new Set());
|
||||
const [knownWordsSummary, setKnownWordsSummary] = useState<{
|
||||
@@ -49,7 +50,7 @@ export function OverviewTab({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
}, [refreshKey]);
|
||||
|
||||
const handleDeleteSession = async (session: SessionSummary) => {
|
||||
if (!(await confirmSessionDelete())) return;
|
||||
@@ -58,6 +59,7 @@ export function OverviewTab({
|
||||
try {
|
||||
await apiClient.deleteSession(session.sessionId);
|
||||
setSessions((prev) => prev.filter((s) => s.sessionId !== session.sessionId));
|
||||
setRefreshKey((prev) => prev + 1);
|
||||
} catch (err) {
|
||||
setDeleteError(err instanceof Error ? err.message : 'Failed to delete session.');
|
||||
} finally {
|
||||
@@ -82,6 +84,7 @@ export function OverviewTab({
|
||||
await apiClient.deleteSessions(ids);
|
||||
const idSet = new Set(ids);
|
||||
setSessions((prev) => prev.filter((s) => !idSet.has(s.sessionId)));
|
||||
setRefreshKey((prev) => prev + 1);
|
||||
} catch (err) {
|
||||
setDeleteError(err instanceof Error ? err.message : 'Failed to delete sessions.');
|
||||
} finally {
|
||||
@@ -108,6 +111,7 @@ export function OverviewTab({
|
||||
await apiClient.deleteSessions(ids);
|
||||
const idSet = new Set(ids);
|
||||
setSessions((prev) => prev.filter((s) => !idSet.has(s.sessionId)));
|
||||
setRefreshKey((prev) => prev + 1);
|
||||
} catch (err) {
|
||||
setDeleteError(err instanceof Error ? err.message : 'Failed to delete sessions.');
|
||||
} finally {
|
||||
@@ -119,9 +123,32 @@ export function OverviewTab({
|
||||
}
|
||||
};
|
||||
|
||||
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 refreshError = error ?? calendarError;
|
||||
const errorNotice = refreshError ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="flex items-center justify-between gap-3 rounded-lg border border-ctp-red/30 bg-ctp-red/10 p-3 text-sm text-ctp-red"
|
||||
>
|
||||
<span>
|
||||
{!data
|
||||
? 'Could not load Overview.'
|
||||
: error
|
||||
? 'Could not refresh Overview. Displayed totals may be out of date.'
|
||||
: 'Could not refresh the activity calendar. It may be out of date.'}{' '}
|
||||
{refreshError}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRefreshKey((prev) => prev + 1)}
|
||||
className="shrink-0 rounded border border-ctp-red/30 px-3 py-1 hover:bg-ctp-red/10"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
if (loading && !data) return <div className="text-ctp-overlay2 p-4">Loading...</div>;
|
||||
if (!data) return errorNotice;
|
||||
|
||||
const summary = buildOverviewSummary(data);
|
||||
const streakData = buildStreakCalendar(calendar);
|
||||
@@ -129,6 +156,7 @@ export function OverviewTab({
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{errorNotice}
|
||||
<HeroStats summary={summary} sessions={sessions} />
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState, useEffect } from 'react';
|
||||
import { getStatsClient } from './useStatsApi';
|
||||
import type { OverviewData, SessionSummary } from '../types/stats';
|
||||
|
||||
export function useOverview() {
|
||||
export function useOverview(refreshKey = 0) {
|
||||
const [data, setData] = useState<OverviewData | null>(null);
|
||||
const [sessions, setSessions] = useState<SessionSummary[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -30,7 +30,7 @@ export function useOverview() {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
}, [refreshKey]);
|
||||
|
||||
return { data, sessions, setSessions, loading, error };
|
||||
}
|
||||
@@ -2,13 +2,15 @@ import { useState, useEffect } from 'react';
|
||||
import { getStatsClient } from './useStatsApi';
|
||||
import type { StreakCalendarDay } from '../types/stats';
|
||||
|
||||
export function useStreakCalendar(days = 90) {
|
||||
export function useStreakCalendar(days = 90, refreshKey = 0) {
|
||||
const [calendar, setCalendar] = useState<StreakCalendarDay[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
getStatsClient()
|
||||
.getStreakCalendar(days)
|
||||
.then((data) => {
|
||||
@@ -23,7 +25,7 @@ export function useStreakCalendar(days = 90) {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [days]);
|
||||
}, [days, refreshKey]);
|
||||
|
||||
return { calendar, loading, error };
|
||||
}
|
||||
Reference in new issue
Block a user