mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-11-11 04:46:07 -08:00
fix(stats): keep Overview usable when refresh fails
This commit is contained in:
5 files changed
+96
-6
No files matched your search
@@ -4,4 +4,4 @@ area: stats
|
|||||||
- Fixed sentence mining (single and multi-line) counting each created card twice in session and lifetime 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 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 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.
|
- Fixed Overview totals, charts, activity calendar, and vocabulary summary staying stale after deleting sessions. Failed refreshes keep the dashboard visible and offer a retry.
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
type: docs
|
type: docs
|
||||||
area: stats
|
area: stats
|
||||||
|
|
||||||
- Documented the automatic Overview refresh after deleting sessions or session groups.
|
- Documented the automatic Overview refresh after deleting sessions or session groups and retrying failed refreshes.
|
||||||
@@ -42,7 +42,7 @@ To share stats and watch history between machines, use [`subminer sync <host>`](
|
|||||||
|
|
||||||
Recent sessions, a streak calendar, watch-time history, and totals for completed episodes and shows.
|
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.
|
Deleting a session or session group refreshes the Overview totals, charts, calendar, and vocabulary summary automatically. If a refresh fails, Overview keeps the previous totals visible and offers **Retry**.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
|||||||
@@ -195,3 +195,72 @@ for (const mode of ['session', 'day', 'anime', 'failed'] as const) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const failedRead of ['overview', 'sessions'] 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 () => [];
|
||||||
|
apiClient.getKnownWordsSummary = async () => ({
|
||||||
|
totalUniqueWords: 0,
|
||||||
|
knownWordCount: 0,
|
||||||
|
});
|
||||||
|
apiClient.deleteSession = async () => {
|
||||||
|
sessions = [];
|
||||||
|
refreshing = true;
|
||||||
|
};
|
||||||
|
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'), '2');
|
||||||
|
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'), '2');
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -123,9 +123,29 @@ export function OverviewTab({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) return <div className="text-ctp-overlay2 p-4">Loading...</div>;
|
const errorNotice = error ? (
|
||||||
if (error) return <div className="text-ctp-red p-4">Error: {error}</div>;
|
<div
|
||||||
if (!data) return null;
|
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 refresh Overview. Displayed totals may be out of date.'
|
||||||
|
: 'Could not load Overview.'}{' '}
|
||||||
|
{error}
|
||||||
|
</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 summary = buildOverviewSummary(data);
|
||||||
const streakData = buildStreakCalendar(calendar);
|
const streakData = buildStreakCalendar(calendar);
|
||||||
@@ -133,6 +153,7 @@ export function OverviewTab({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
{errorNotice}
|
||||||
<HeroStats summary={summary} sessions={sessions} />
|
<HeroStats summary={summary} sessions={sessions} />
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
|||||||
Reference in new issue
Block a user