From f94eb84179c25219b0ece7cc9b29a4fde2ea40b3 Mon Sep 17 00:00:00 2001 From: sudacode Date: Fri, 2 Oct 2026 10:51:01 -0700 Subject: [PATCH] fix(stats): refresh Overview data after deleting sessions - Add a refresh key to OverviewTab that refetches overview, streak calendar, and known-words summary after single, day, or anime session deletes - Reset loading/error state in useStreakCalendar on refetch - Add OverviewTab tests covering each delete mode and a failed delete - Document the automatic refresh and add changelog fragments --- changes/stats-mining-and-delete-accuracy.md | 1 + changes/stats-overview-refresh-docs.md | 4 + docs-site/immersion-tracking.md | 2 + .../components/overview/OverviewTab.test.tsx | 197 ++++++++++++++++++ stats/src/components/overview/OverviewTab.tsx | 10 +- stats/src/hooks/useOverview.ts | 4 +- stats/src/hooks/useStreakCalendar.ts | 6 +- 7 files changed, 217 insertions(+), 7 deletions(-) create mode 100644 changes/stats-overview-refresh-docs.md create mode 100644 stats/src/components/overview/OverviewTab.test.tsx diff --git a/changes/stats-mining-and-delete-accuracy.md b/changes/stats-mining-and-delete-accuracy.md index 9247bbb8..d6e5c6a6 100644 --- a/changes/stats-mining-and-delete-accuracy.md +++ b/changes/stats-mining-and-delete-accuracy.md @@ -4,3 +4,4 @@ area: stats - Fixed sentence mining (single and multi-line) counting each created card twice in session and lifetime stats. - Fixed deleting a session leaving word and kanji "last seen" dates pointing at the deleted session; affected rows are repaired once in the background on next launch. - Fixed re-seen words that the vocabulary filter hides still being counted in the new-words charts, so charts and the vocabulary summary now agree. Existing mismatches are repaired by the same background pass. +- Fixed Overview totals, charts, activity calendar, and vocabulary summary staying stale after deleting sessions. diff --git a/changes/stats-overview-refresh-docs.md b/changes/stats-overview-refresh-docs.md new file mode 100644 index 00000000..c184d85f --- /dev/null +++ b/changes/stats-overview-refresh-docs.md @@ -0,0 +1,4 @@ +type: docs +area: stats + +- Documented the automatic Overview refresh after deleting sessions or session groups. diff --git a/docs-site/immersion-tracking.md b/docs-site/immersion-tracking.md index 5aa0624a..74cc62cd 100644 --- a/docs-site/immersion-tracking.md +++ b/docs-site/immersion-tracking.md @@ -42,6 +42,8 @@ To share stats and watch history between machines, use [`subminer sync `]( Recent sessions, a streak calendar, watch-time history, and totals for completed episodes and shows. +Deleting a session or session group refreshes the Overview totals, charts, calendar, and vocabulary summary automatically. + ![Stats Overview](/screenshots/stats-overview.png) ### Library diff --git a/stats/src/components/overview/OverviewTab.test.tsx b/stats/src/components/overview/OverviewTab.test.tsx new file mode 100644 index 00000000..041de23b --- /dev/null +++ b/stats/src/components/overview/OverviewTab.test.tsx @@ -0,0 +1,197 @@ +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( + {}} 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(); + } + }); +} diff --git a/stats/src/components/overview/OverviewTab.tsx b/stats/src/components/overview/OverviewTab.tsx index 767aa3fa..291cd6ba 100644 --- a/stats/src/components/overview/OverviewTab.tsx +++ b/stats/src/components/overview/OverviewTab.tsx @@ -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 } = useStreakCalendar(90, refreshKey); const [deleteError, setDeleteError] = useState(null); const [deletingIds, setDeletingIds] = useState>(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 { diff --git a/stats/src/hooks/useOverview.ts b/stats/src/hooks/useOverview.ts index ac73cd49..a5174277 100644 --- a/stats/src/hooks/useOverview.ts +++ b/stats/src/hooks/useOverview.ts @@ -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(null); const [sessions, setSessions] = useState([]); const [loading, setLoading] = useState(true); @@ -30,7 +30,7 @@ export function useOverview() { return () => { cancelled = true; }; - }, []); + }, [refreshKey]); return { data, sessions, setSessions, loading, error }; } diff --git a/stats/src/hooks/useStreakCalendar.ts b/stats/src/hooks/useStreakCalendar.ts index 02a930c3..cdb7c47a 100644 --- a/stats/src/hooks/useStreakCalendar.ts +++ b/stats/src/hooks/useStreakCalendar.ts @@ -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([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(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 }; }