fix(stats): count mined cards once and keep lexical stats exact after deletes (#280)

This commit is contained in:
sudacode authored and GitHub committed 2026-10-03 21:16:12 -07:00
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();
}
});
}
+34 -6
View File
@@ -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 -2
View File
@@ -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 };
}
+4 -2
View File
@@ -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 };
}