Compare commits

...
6 Commits
Author SHA1 Message Date
sudacode 6e9ec3722e fix(stats): keep Overview usable when refresh fails 2026-10-02 11:14:14 -07:00
sudacode f94eb84179 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
2026-10-02 10:51:01 -07:00
sudacode 0b02f4ec7b fix(stats): drop first_seen repair from lexical rollup rebuild
A stale first_seen is indistinguishable from real history whose occurrences
retention pruned, and frequency does not reliably separate them (it diverges
from surviving occurrences on databases with neither retention nor
pre-occurrence history), so the guarded repair either did nothing or risked
overwriting correct dates. The delete path already keeps first_seen exact for
new deletes; the rebuild now repairs only last_seen.
2026-10-01 23:59:51 -07:00
sudacode bf2ea99178 fix(stats): keep first_seen for words with retention-pruned history
- Only repair stale first_seen when frequency matches surviving occurrences
- Rows whose older lines were pruned (or predate occurrence tables) keep their original first_seen
- Extend rollup rebuild test with a retention-pruned word
2026-10-01 23:27:40 -07:00
sudacode ce680b746a fix(stats): move first_seen correctly when deleting earliest session
- Decide first_seen movement against the surviving min seen_ms, since
  stored bounds are stamped at queue time and may predate seen_ms
- Extend v3 rollup repair to fix stale first_seen as well as last_seen,
  allowing slack for queue-to-flush lag
2026-10-01 23:12:09 -07:00
sudacode 4677f5624b fix(stats): count mined cards once and keep lexical stats exact after deletes
- Card creation now owns mined-card counting; remove the outer single and
  multi-line mining increments that double counted each card.
- Preserve fractional first_seen/last_seen precision when session deletes
  compare and refresh lexical aggregates.
- Derive vocabulary_visible from the stored word row on every write so the
  new-word charts agree with the filtered vocabulary summary.
- Bump the lexical rollup version so existing databases re-derive visibility
  and repair stale last_seen values once in the background worker.
2026-10-01 22:56:38 -07:00
24 changed files with 567 additions and 127 deletions

No files matched your search

@@ -0,0 +1,7 @@
type: fixed
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. Failed refreshes keep the dashboard visible and offer a retry.
+4
View File
@@ -0,0 +1,4 @@
type: docs
area: stats
- Documented the automatic Overview refresh after deleting sessions or session groups and retrying failed refreshes.
+2
View File
@@ -42,6 +42,8 @@ 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. If a refresh fails, Overview keeps the previous totals visible and offers **Retry**.
![Stats Overview](/screenshots/stats-overview.png) ![Stats Overview](/screenshots/stats-overview.png)
### Library ### Library
@@ -30,7 +30,13 @@ function cleanupDbPath(dbPath: string): void {
*/ */
function seed( function seed(
db: DatabaseSync, db: DatabaseSync,
lines: Array<{ session: 1 | 2; wordId: number; dayOffset: number; count?: number }>, lines: Array<{
session: 1 | 2;
wordId: number;
dayOffset: number;
msOffset?: number;
count?: number;
}>,
options: { legacyRows?: boolean } = {}, options: { legacyRows?: boolean } = {},
): void { ): void {
db.exec(` db.exec(`
@@ -65,7 +71,7 @@ function seed(
let lineId = 0; let lineId = 0;
for (const line of lines) { for (const line of lines) {
lineId += 1; lineId += 1;
const seenMs = BASE_MS + line.dayOffset * DAY_MS; const seenMs = BASE_MS + line.dayOffset * DAY_MS + (line.msOffset ?? 0);
insertLine.run(lineId, line.session, line.session, lineId, `line ${lineId}`, seenMs, seenMs); insertLine.run(lineId, line.session, line.session, lineId, `line ${lineId}`, seenMs, seenMs);
insertWord.run(line.wordId, `語${line.wordId}`, `語${line.wordId}`); insertWord.run(line.wordId, `語${line.wordId}`, `語${line.wordId}`);
if (options.legacyRows) { if (options.legacyRows) {
@@ -83,13 +89,13 @@ function seed(
FROM imm_word_line_occurrences o WHERE o.word_id = imm_words.id FROM imm_word_line_occurrences o WHERE o.word_id = imm_words.id
), ),
first_seen = ( first_seen = (
SELECT MIN(sl.CREATED_DATE) / 1000 SELECT MIN(sl.CREATED_DATE) / 1000.0
FROM imm_word_line_occurrences o FROM imm_word_line_occurrences o
JOIN imm_subtitle_lines sl ON sl.line_id = o.line_id JOIN imm_subtitle_lines sl ON sl.line_id = o.line_id
WHERE o.word_id = imm_words.id WHERE o.word_id = imm_words.id
), ),
last_seen = ( last_seen = (
SELECT MAX(sl.LAST_UPDATE_DATE) / 1000 SELECT MAX(sl.LAST_UPDATE_DATE) / 1000.0
FROM imm_word_line_occurrences o FROM imm_word_line_occurrences o
JOIN imm_subtitle_lines sl ON sl.line_id = o.line_id JOIN imm_subtitle_lines sl ON sl.line_id = o.line_id
WHERE o.word_id = imm_words.id WHERE o.word_id = imm_words.id
@@ -200,6 +206,52 @@ test('deleting the latest session moves last_seen back to the surviving line', (
} }
}); });
for (const legacyRows of [false, true]) {
test(`deleting the latest session keeps fractional seen timestamps exact (legacyRows=${legacyRows})`, () => {
// Stored seen values are fractional seconds; whole-second rounding used to miss
// the removed extreme (or truncate the refreshed one).
const { db, dbPath } = createDb(
[
{ session: 1, wordId: 22, dayOffset: 0, msOffset: 251 },
{ session: 2, wordId: 22, dayOffset: 5, msOffset: 882 },
],
{ legacyRows },
);
try {
deleteSession(db, 2);
const word = readWord(db, 22);
assert.equal(word?.frequency, 1);
assert.equal(word?.firstSeen, (BASE_MS + 251) / 1000);
assert.equal(word?.lastSeen, (BASE_MS + 251) / 1000);
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
}
test('deleting the earliest session moves first_seen even when the stored bound predates seen_ms', () => {
const { db, dbPath } = createDb([
{ session: 1, wordId: 23, dayOffset: 0, msOffset: 400 },
{ session: 2, wordId: 23, dayOffset: 5, msOffset: 400 },
]);
try {
// first_seen is stamped when the line is queued (and older refreshes rounded
// it down), so it sits slightly before the occurrence's flush-time seen_ms.
db.prepare('UPDATE imm_words SET first_seen = ? WHERE id = 23').run(BASE_MS / 1000);
deleteSession(db, 1);
assert.equal(readWord(db, 23)?.firstSeen, (BASE_MS + 5 * DAY_MS + 400) / 1000);
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
test('deleting an interior occurrence leaves the surrounding extremes untouched', () => { test('deleting an interior occurrence leaves the surrounding extremes untouched', () => {
// Session 2 carries the middle occurrence; sessions bracket it in time. // Session 2 carries the middle occurrence; sessions bracket it in time.
const { db, dbPath } = createDb([ const { db, dbPath } = createDb([
@@ -4567,13 +4567,13 @@ test('deleteSession rebuilds word and kanji aggregates from retained subtitle li
assert.ok(sharedWordRow); assert.ok(sharedWordRow);
assert.equal(sharedWordRow.frequency, 1); assert.equal(sharedWordRow.frequency, 1);
assert.equal(sharedWordRow.first_seen, Math.floor(keptTs / 1000)); assert.equal(sharedWordRow.first_seen, keptTs / 1000);
assert.equal(sharedWordRow.last_seen, Math.floor(keptTs / 1000)); assert.equal(sharedWordRow.last_seen, keptTs / 1000);
assert.equal(deletedOnlyWordRow ?? null, null); assert.equal(deletedOnlyWordRow ?? null, null);
assert.ok(sharedKanjiRow); assert.ok(sharedKanjiRow);
assert.equal(sharedKanjiRow.frequency, 1); assert.equal(sharedKanjiRow.frequency, 1);
assert.equal(sharedKanjiRow.first_seen, Math.floor(keptTs / 1000)); assert.equal(sharedKanjiRow.first_seen, keptTs / 1000);
assert.equal(sharedKanjiRow.last_seen, Math.floor(keptTs / 1000)); assert.equal(sharedKanjiRow.last_seen, keptTs / 1000);
assert.equal(deletedOnlyKanjiRow ?? null, null); assert.equal(deletedOnlyKanjiRow ?? null, null);
} finally { } finally {
db.close(); db.close();
@@ -16,7 +16,8 @@ import {
} from './query-lexical'; } from './query-lexical';
import { Database } from './sqlite'; import { Database } from './sqlite';
import type { DatabaseSync } from './sqlite'; import type { DatabaseSync } from './sqlite';
import { ensureSchema } from './storage'; import { createTrackerPreparedStatements, ensureSchema, executeQueuedWrite } from './storage';
import { BASE_MS, createDb, seedEndedSession, seedVideo } from './__tests__/lifetime-test-fixtures';
function makeDbPath(): string { function makeDbPath(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-lexical-rollups-')); const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-lexical-rollups-'));
@@ -125,6 +126,121 @@ test('lexical rollup rebuild excludes rows hidden by vocabulary persistence rule
} }
}); });
test('re-seeing a filtered legacy word keeps charts consistent with the summary', () => {
const db = createDb();
try {
const videoId = seedVideo(db, null, 'episode');
const sessionId = seedEndedSession(db, videoId, BASE_MS, { activeMs: 1_000 });
const firstSeen = BASE_MS / 1000;
// Legacy row whose stored merged-token POS fails the persistence filter.
db.prepare(
`INSERT INTO imm_words(
headword, word, reading, part_of_speech, pos1, pos2, pos3, first_seen, last_seen, frequency
) VALUES ('二死', 'にし', 'にし', 'other', '助詞|動詞', '格助詞|自立', '一般|*', ?, ?, 1)`,
).run(firstSeen, firstSeen);
rebuildLexicalDailyRollups(db);
// The incoming token passes the filter, but the upsert keeps the stored POS.
executeQueuedWrite(
{
kind: 'subtitleLine',
sessionId,
videoId,
lineIndex: 1,
segmentStartMs: null,
segmentEndMs: null,
text: 'にし',
wordOccurrences: [
{
headword: '二死',
word: 'にし',
reading: 'にし',
partOfSpeech: 'verb',
pos1: '動詞',
pos2: '自立',
pos3: '*',
frequencyRank: null,
occurrenceCount: 1,
},
],
kanjiOccurrences: [],
firstSeen: firstSeen + 60,
lastSeen: firstSeen + 60,
},
createTrackerPreparedStatements(db),
);
const plotted = getVocabularyChartData(db).newWordsTimeline.reduce(
(sum, day) => sum + day.wordCount,
0,
);
assert.equal(getVocabularySummary(db, null).uniqueWords, 0);
assert.equal(plotted, 0);
} finally {
db.close();
}
});
test('lexical rollup rebuild repairs last_seen left pointing at deleted history', () => {
const db = createDb();
try {
const videoId = seedVideo(db, null, 'episode');
const sessionId = seedEndedSession(db, videoId, BASE_MS, { activeMs: 1_000 });
const lineId = Number(
db
.prepare(
`INSERT INTO imm_subtitle_lines(session_id, video_id, line_index, text, CREATED_DATE, LAST_UPDATE_DATE)
VALUES (?, ?, 1, '猫', ?, ?)`,
)
.run(sessionId, videoId, BASE_MS, BASE_MS).lastInsertRowid,
);
const seen = (BASE_MS + 251) / 1000;
// first_seen this early may be real history pruned by retention; never touched.
const earlyFirst = seen - 86_400.5;
const staleLast = seen + 86_400.631;
db.prepare(
`INSERT INTO imm_words(id, headword, word, reading, first_seen, last_seen, frequency)
VALUES (1, '猫', '猫', 'ねこ', ?, ?, 1), (2, '犬', '犬', 'いぬ', ?, ?, 1)`,
).run(earlyFirst, staleLast, seen - 0.1, seen - 0.1);
db.prepare(
`INSERT INTO imm_kanji(id, kanji, first_seen, last_seen, frequency) VALUES (1, '猫', ?, ?, 1)`,
).run(earlyFirst, staleLast);
db.prepare(
`INSERT INTO imm_word_line_occurrences(line_id, word_id, occurrence_count, seen_ms)
VALUES (?, 1, 1, ?), (?, 2, 1, ?)`,
).run(lineId, BASE_MS + 251, lineId, BASE_MS + 251);
db.prepare(
`INSERT INTO imm_kanji_line_occurrences(line_id, kanji_id, occurrence_count, seen_ms)
VALUES (?, 1, 1, ?)`,
).run(lineId, BASE_MS + 251);
rebuildLexicalDailyRollups(db);
const bounds = (table: string): Array<[number, number]> =>
(
db
.prepare(`SELECT first_seen AS first, last_seen AS last FROM ${table} ORDER BY id`)
.all() as Array<{
first: number;
last: number;
}>
).map((row) => [row.first, row.last]);
assert.deepEqual(
bounds('imm_words'),
[
[earlyFirst, seen],
[seen - 0.1, seen - 0.1],
],
'healthy rows are untouched',
);
assert.deepEqual(bounds('imm_kanji'), [[earlyFirst, seen]]);
} finally {
db.close();
}
});
test('lexical rollup rebuild tolerates nullable legacy vocabulary text', () => { test('lexical rollup rebuild tolerates nullable legacy vocabulary text', () => {
const dbPath = makeDbPath(); const dbPath = makeDbPath();
const db = new Database(dbPath); const db = new Database(dbPath);
@@ -268,7 +384,7 @@ test('current lexical rollup readiness accepts legacy integer state storage', ()
state_value INTEGER NOT NULL state_value INTEGER NOT NULL
); );
INSERT INTO imm_rollup_state(state_key, state_value) INSERT INTO imm_rollup_state(state_key, state_value)
VALUES ('lexical_daily_rollups_version', 2); VALUES ('lexical_daily_rollups_version', 3);
`); `);
assert.equal(areLexicalDailyRollupsReady(db), true); assert.equal(areLexicalDailyRollupsReady(db), true);
@@ -18,7 +18,9 @@ const LOCAL_EPOCH_DAY_SQL = `
) - 2440587.5 AS INTEGER) ) - 2440587.5 AS INTEGER)
`; `;
const LEXICAL_DAILY_ROLLUP_VERSION = '2'; // v3 re-derives visibility for rows a re-seen word forced visible and repairs
// last_seen values left behind by session deletes.
const LEXICAL_DAILY_ROLLUP_VERSION = '3';
const LEXICAL_DAILY_ROLLUP_VERSION_KEY = 'lexical_daily_rollups_version'; const LEXICAL_DAILY_ROLLUP_VERSION_KEY = 'lexical_daily_rollups_version';
const VOCABULARY_VISIBILITY_SCAN_BATCH_SIZE = 5_000; const VOCABULARY_VISIBILITY_SCAN_BATCH_SIZE = 5_000;
@@ -26,6 +28,32 @@ export function localEpochDaySql(value: string): string {
return LOCAL_EPOCH_DAY_SQL.replaceAll('%VALUE%', value); return LOCAL_EPOCH_DAY_SQL.replaceAll('%VALUE%', value);
} }
// Session deletes once rounded removed timestamps to whole seconds, missing
// fractional last_seen extremes that pointed at deleted history. last_seen is
// stamped at queue time, before seen_ms is stamped at flush, and retention
// prunes the oldest sessions first, so healthy rows never exceed their newest
// surviving occurrence. Rows with undated occurrences are skipped.
//
// first_seen is deliberately not repaired: a stale bound is indistinguishable
// from real history whose occurrences retention pruned, and frequency does not
// reliably tell them apart.
function repairStaleLastSeen(db: DatabaseSync): void {
for (const [table, occurrenceTable, col] of [
['imm_words', 'imm_word_line_occurrences', 'word_id'],
['imm_kanji', 'imm_kanji_line_occurrences', 'kanji_id'],
] as const) {
const newestSeen = `(SELECT MAX(o.seen_ms) FROM ${occurrenceTable} o WHERE o.${col} = ${table}.id) / 1000.0`;
db.exec(`
UPDATE ${table}
SET last_seen = ${newestSeen}
WHERE last_seen > ${newestSeen}
AND NOT EXISTS (
SELECT 1 FROM ${occurrenceTable} o WHERE o.${col} = ${table}.id AND o.seen_ms IS NULL
)
`);
}
}
function createWordRollupTriggers(db: DatabaseSync): void { function createWordRollupTriggers(db: DatabaseSync): void {
const dayForNew = localEpochDaySql('NEW.first_seen'); const dayForNew = localEpochDaySql('NEW.first_seen');
const dayForOld = localEpochDaySql('OLD.first_seen'); const dayForOld = localEpochDaySql('OLD.first_seen');
@@ -186,6 +214,7 @@ export function rebuildLexicalDailyRollups(db: DatabaseSync): void {
lastId = vocabularyRows[vocabularyRows.length - 1]!.id; lastId = vocabularyRows[vocabularyRows.length - 1]!.id;
if (vocabularyRows.length < VOCABULARY_VISIBILITY_SCAN_BATCH_SIZE) break; if (vocabularyRows.length < VOCABULARY_VISIBILITY_SCAN_BATCH_SIZE) break;
} }
repairStaleLastSeen(db);
db.exec('DELETE FROM imm_lexical_daily_rollups'); db.exec('DELETE FROM imm_lexical_daily_rollups');
db.exec(` db.exec(`
INSERT INTO imm_lexical_daily_rollups(epoch_day, word_count, word_count_without_names, kanji_count) INSERT INTO imm_lexical_daily_rollups(epoch_day, word_count, word_count_without_names, kanji_count)
@@ -298,9 +298,11 @@ export function planLexicalRemovalsForVideos(
return planLexicalRemovals(db, `sl.video_id IN (${makePlaceholders(videoIds)})`, videoIds); return planLexicalRemovals(db, `sl.video_id IN (${makePlaceholders(videoIds)})`, videoIds);
} }
// first_seen/last_seen are REAL seconds that keep millisecond fractions; never round
// them, or comparisons against stored extremes miss and refreshes drift.
function toStoredSeenSeconds(ms: number | null): number | null { function toStoredSeenSeconds(ms: number | null): number | null {
if (ms === null || !Number.isFinite(ms)) return null; if (ms === null || !Number.isFinite(ms)) return null;
return Math.floor(ms / 1000); return ms / 1000;
} }
/** /**
@@ -380,11 +382,16 @@ function applyRemovalsForEntity(
continue; continue;
} }
const removedFirstSeen = toStoredSeenSeconds(removal.removedFirstSeenMs); // Stored bounds are stamped when a line is queued, before its seen_ms is
// stamped at flush, and older refreshes rounded them down to whole seconds.
// So first_seen can sit just before the occurrence that produced it; decide
// against the surviving minimum instead. last_seen errs the safe way.
const minSeenMs = (minSeenStmt.get(removal.id) as { value: number | null }).value;
const removedLastSeen = toStoredSeenSeconds(removal.removedLastSeenMs); const removedLastSeen = toStoredSeenSeconds(removal.removedLastSeenMs);
const firstSeenMayHaveMoved = const firstSeenMayHaveMoved =
current.firstSeen === null || current.firstSeen === null ||
(removedFirstSeen !== null && removedFirstSeen <= current.firstSeen); (removal.removedFirstSeenMs !== null &&
(minSeenMs === null || Number(removal.removedFirstSeenMs) <= Number(minSeenMs)));
const lastSeenMayHaveMoved = const lastSeenMayHaveMoved =
current.lastSeen === null || current.lastSeen === null ||
(removedLastSeen !== null && removedLastSeen >= current.lastSeen); (removedLastSeen !== null && removedLastSeen >= current.lastSeen);
@@ -395,7 +402,6 @@ function applyRemovalsForEntity(
needsExactRefresh.push(removal.id); needsExactRefresh.push(removal.id);
continue; continue;
} }
const minSeenMs = (minSeenStmt.get(removal.id) as { value: number | null }).value;
const maxSeenMs = (maxSeenStmt.get(removal.id) as { value: number | null }).value; const maxSeenMs = (maxSeenStmt.get(removal.id) as { value: number | null }).value;
if (minSeenMs === null || maxSeenMs === null) { if (minSeenMs === null || maxSeenMs === null) {
// Frequency says occurrences remain but none exist: stale row, let the // Frequency says occurrences remain but none exist: stale row, let the
@@ -405,8 +411,8 @@ function applyRemovalsForEntity(
} }
updateAggregatesStmt.run( updateAggregatesStmt.run(
nextFrequency, nextFrequency,
Math.floor(Number(minSeenMs) / 1000), toStoredSeenSeconds(Number(minSeenMs)),
Math.floor(Number(maxSeenMs) / 1000), toStoredSeenSeconds(Number(maxSeenMs)),
removal.id, removal.id,
); );
continue; continue;
@@ -471,8 +477,8 @@ function refreshWordAggregates(db: DatabaseSync, wordIds: number[]): void {
} }
updateStmt.run( updateStmt.run(
row.frequency, row.frequency,
Math.floor(row.firstSeen / 1000), toStoredSeenSeconds(row.firstSeen),
Math.floor(row.lastSeen / 1000), toStoredSeenSeconds(row.lastSeen),
row.wordId, row.wordId,
); );
} }
@@ -527,8 +533,8 @@ function refreshKanjiAggregates(db: DatabaseSync, kanjiIds: number[]): void {
} }
updateStmt.run( updateStmt.run(
row.frequency, row.frequency,
Math.floor(row.firstSeen / 1000), toStoredSeenSeconds(row.firstSeen),
Math.floor(row.lastSeen / 1000), toStoredSeenSeconds(row.lastSeen),
row.kanjiId, row.kanjiId,
); );
} }
+20 -9
View File
@@ -6,6 +6,7 @@ import { normalizeTitleIdentity } from '../../utils/title-normalization';
import type { DatabaseSync } from './sqlite'; import type { DatabaseSync } from './sqlite';
import { nowMs } from './time'; import { nowMs } from './time';
import { ensureLexicalDailyRollupTables, markLexicalDailyRollupsReady } from './lexical-rollups'; import { ensureLexicalDailyRollupTables, markLexicalDailyRollupsReady } from './lexical-rollups';
import { isVocabularyStatsRowVisible, type VocabularyVisibilityRow } from './vocabulary-visibility';
import { SCHEMA_VERSION } from './types'; import { SCHEMA_VERSION } from './types';
import type { QueuedWrite, VideoMetadata, YoutubeVideoMetadata } from './types'; import type { QueuedWrite, VideoMetadata, YoutubeVideoMetadata } from './types';
import { toDbMs, toDbTimestamp } from './query-shared'; import { toDbMs, toDbTimestamp } from './query-shared';
@@ -17,7 +18,8 @@ export interface TrackerPreparedStatements {
wordUpsertStmt: ReturnType<DatabaseSync['prepare']>; wordUpsertStmt: ReturnType<DatabaseSync['prepare']>;
kanjiUpsertStmt: ReturnType<DatabaseSync['prepare']>; kanjiUpsertStmt: ReturnType<DatabaseSync['prepare']>;
subtitleLineInsertStmt: ReturnType<DatabaseSync['prepare']>; subtitleLineInsertStmt: ReturnType<DatabaseSync['prepare']>;
wordIdSelectStmt: ReturnType<DatabaseSync['prepare']>; wordSelectStmt: ReturnType<DatabaseSync['prepare']>;
wordVisibilityUpdateStmt: ReturnType<DatabaseSync['prepare']>;
kanjiIdSelectStmt: ReturnType<DatabaseSync['prepare']>; kanjiIdSelectStmt: ReturnType<DatabaseSync['prepare']>;
wordLineOccurrenceUpsertStmt: ReturnType<DatabaseSync['prepare']>; wordLineOccurrenceUpsertStmt: ReturnType<DatabaseSync['prepare']>;
kanjiLineOccurrenceUpsertStmt: ReturnType<DatabaseSync['prepare']>; kanjiLineOccurrenceUpsertStmt: ReturnType<DatabaseSync['prepare']>;
@@ -1759,7 +1761,6 @@ export function createTrackerPreparedStatements(db: DatabaseSync): TrackerPrepar
pos1 = COALESCE(NULLIF(imm_words.pos1, ''), excluded.pos1), pos1 = COALESCE(NULLIF(imm_words.pos1, ''), excluded.pos1),
pos2 = COALESCE(NULLIF(imm_words.pos2, ''), excluded.pos2), pos2 = COALESCE(NULLIF(imm_words.pos2, ''), excluded.pos2),
pos3 = COALESCE(NULLIF(imm_words.pos3, ''), excluded.pos3), pos3 = COALESCE(NULLIF(imm_words.pos3, ''), excluded.pos3),
vocabulary_visible = 1,
first_seen = MIN(COALESCE(first_seen, excluded.first_seen), excluded.first_seen), first_seen = MIN(COALESCE(first_seen, excluded.first_seen), excluded.first_seen),
last_seen = MAX(COALESCE(last_seen, excluded.last_seen), excluded.last_seen), last_seen = MAX(COALESCE(last_seen, excluded.last_seen), excluded.last_seen),
frequency_rank = CASE frequency_rank = CASE
@@ -1787,10 +1788,16 @@ export function createTrackerPreparedStatements(db: DatabaseSync): TrackerPrepar
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
) )
`), `),
wordIdSelectStmt: db.prepare(` wordSelectStmt: db.prepare(`
SELECT id FROM imm_words SELECT id, word, headword, reading, part_of_speech AS partOfSpeech,
pos1, pos2, pos3, frequency_rank AS frequencyRank,
vocabulary_visible AS vocabularyVisible
FROM imm_words
WHERE headword = ? AND word = ? AND reading = ? WHERE headword = ? AND word = ? AND reading = ?
`), `),
wordVisibilityUpdateStmt: db.prepare(`
UPDATE imm_words SET vocabulary_visible = ? WHERE id = ?
`),
kanjiIdSelectStmt: db.prepare(` kanjiIdSelectStmt: db.prepare(`
SELECT id FROM imm_kanji SELECT id FROM imm_kanji
WHERE kanji = ? WHERE kanji = ?
@@ -1842,14 +1849,18 @@ function incrementWordAggregate(
occurrence.frequencyRank ?? null, occurrence.frequencyRank ?? null,
); );
} }
const row = stmts.wordIdSelectStmt.get( const row = stmts.wordSelectStmt.get(occurrence.headword, occurrence.word, occurrence.reading) as
occurrence.headword, | (VocabularyVisibilityRow & { id: number; vocabularyVisible: number })
occurrence.word, | null;
occurrence.reading,
) as { id: number } | null;
if (!row?.id) { if (!row?.id) {
throw new Error(`Failed to resolve imm_words id for ${occurrence.headword}`); throw new Error(`Failed to resolve imm_words id for ${occurrence.headword}`);
} }
// The upsert keeps the first-seen POS metadata, so derive visibility from the
// stored row (as the vocabulary summary does) rather than the incoming token.
const visible = isVocabularyStatsRowVisible(row) ? 1 : 0;
if (row.vocabularyVisible !== visible) {
stmts.wordVisibilityUpdateStmt.run(visible, row.id);
}
return row.id; return row.id;
} }
-35
View File
@@ -276,7 +276,6 @@ test('handleMultiCopyDigit copies backward from the current subtitle after a bac
test('handleMineSentenceDigit reports async create failures', async () => { test('handleMineSentenceDigit reports async create failures', async () => {
const osd: string[] = []; const osd: string[] = [];
const logs: Array<{ message: string; err: unknown }> = []; const logs: Array<{ message: string; err: unknown }> = [];
let cardsMined = 0;
handleMineSentenceDigit(2, { handleMineSentenceDigit(2, {
subtitleTimingTracker: { subtitleTimingTracker: {
@@ -296,9 +295,6 @@ test('handleMineSentenceDigit reports async create failures', async () => {
getCurrentSecondarySubText: () => 'sub2', getCurrentSecondarySubText: () => 'sub2',
showMpvOsd: (text) => osd.push(text), showMpvOsd: (text) => osd.push(text),
logError: (message, err) => logs.push({ message, err }), logError: (message, err) => logs.push({ message, err }),
onCardsMined: (count) => {
cardsMined += count;
},
}); });
await new Promise((resolve) => setImmediate(resolve)); await new Promise((resolve) => setImmediate(resolve));
@@ -307,37 +303,6 @@ test('handleMineSentenceDigit reports async create failures', async () => {
assert.equal(logs[0]?.message, 'mineSentenceMultiple failed:'); assert.equal(logs[0]?.message, 'mineSentenceMultiple failed:');
assert.equal((logs[0]?.err as Error).message, 'mine boom'); assert.equal((logs[0]?.err as Error).message, 'mine boom');
assert.ok(osd.some((entry) => entry.includes('Mine sentence failed: mine boom'))); assert.ok(osd.some((entry) => entry.includes('Mine sentence failed: mine boom')));
assert.equal(cardsMined, 0);
});
test('handleMineSentenceDigit increments successful card count', async () => {
const osd: string[] = [];
let cardsMined = 0;
handleMineSentenceDigit(2, {
subtitleTimingTracker: {
getRecentBlocks: () => ['one', 'two'],
getCurrentSubtitle: () => null,
findTiming: (text) =>
text === 'one' ? { startTime: 1, endTime: 3 } : { startTime: 4, endTime: 7 },
},
ankiIntegration: {
updateLastAddedFromClipboard: async () => {},
triggerFieldGroupingForLastAddedCard: async () => {},
markLastCardAsAudioCard: async () => {},
createSentenceCard: async () => true,
},
getCurrentSecondarySubText: () => 'sub2',
showMpvOsd: (text) => osd.push(text),
logError: () => {},
onCardsMined: (count) => {
cardsMined += count;
},
});
await new Promise((resolve) => setImmediate(resolve));
assert.equal(cardsMined, 1);
}); });
test('handleMineSentenceDigit keeps per-entry timings when subtitle text repeats', async () => { test('handleMineSentenceDigit keeps per-entry timings when subtitle text repeats', async () => {
+1 -7
View File
@@ -201,7 +201,6 @@ export function handleMineSentenceDigit(
getCurrentSecondarySubText: () => string | undefined; getCurrentSecondarySubText: () => string | undefined;
showMpvOsd: (text: string) => void; showMpvOsd: (text: string) => void;
logError: (message: string, err: unknown) => void; logError: (message: string, err: unknown) => void;
onCardsMined?: (count: number) => void;
}, },
): void { ): void {
if (!deps.subtitleTimingTracker || !deps.ankiIntegration) return; if (!deps.subtitleTimingTracker || !deps.ankiIntegration) return;
@@ -233,14 +232,9 @@ export function handleMineSentenceDigit(
entries, entries,
deps.getCurrentSecondarySubText, deps.getCurrentSecondarySubText,
); );
const cardsToMine = 1; // Card creation records the mined card (with its note id) itself.
deps.ankiIntegration deps.ankiIntegration
.createSentenceCard(sentence, rangeStart, rangeEnd, secondarySubText) .createSentenceCard(sentence, rangeStart, rangeEnd, secondarySubText)
.then((created) => {
if (created) {
deps.onCardsMined?.(cardsToMine);
}
})
.catch((err) => { .catch((err) => {
deps.logError('mineSentenceMultiple failed:', err); deps.logError('mineSentenceMultiple failed:', err);
deps.showMpvOsd(`Mine sentence failed: ${(err as Error).message}`); deps.showMpvOsd(`Mine sentence failed: ${(err as Error).message}`);
-8
View File
@@ -5497,10 +5497,6 @@ const buildMineSentenceCardMainDepsHandler = createBuildMineSentenceCardMainDeps
getPrimarySubtitle: () => captureCurrentPrimarySubtitleMiningContext(), getPrimarySubtitle: () => captureCurrentPrimarySubtitleMiningContext(),
showMpvOsd: (text) => overlayNotificationsRuntime.showConfiguredStatusNotification(text), showMpvOsd: (text) => overlayNotificationsRuntime.showConfiguredStatusNotification(text),
mineSentenceCardCore, mineSentenceCardCore,
recordCardsMined: (count, noteIds) => {
ensureImmersionTrackerStarted();
appState.immersionTracker?.recordCardsMined(count, noteIds);
},
}); });
const mineSentenceCardHandler = createMineSentenceCardHandler( const mineSentenceCardHandler = createMineSentenceCardHandler(
buildMineSentenceCardMainDepsHandler(), buildMineSentenceCardMainDepsHandler(),
@@ -5533,10 +5529,6 @@ const buildHandleMineSentenceDigitMainDepsHandler =
logError: (message, err) => { logError: (message, err) => {
logger.error(message, err); logger.error(message, err);
}, },
onCardsMined: (cards) => {
ensureImmersionTrackerStarted();
appState.immersionTracker?.recordCardsMined(cards);
},
handleMineSentenceDigitCore, handleMineSentenceDigitCore,
}); });
const handleMineSentenceDigitMainDeps = buildHandleMineSentenceDigitMainDepsHandler(); const handleMineSentenceDigitMainDeps = buildHandleMineSentenceDigitMainDepsHandler();
@@ -67,7 +67,6 @@ test('anki action main deps builders map callbacks', async () => {
getPrimarySubtitle: () => ({ text: '正式な字幕', startTime: 1, endTime: 3 }), getPrimarySubtitle: () => ({ text: '正式な字幕', startTime: 1, endTime: 3 }),
showMpvOsd: (text) => calls.push(`mine:${text}`), showMpvOsd: (text) => calls.push(`mine:${text}`),
mineSentenceCardCore: async () => true, mineSentenceCardCore: async () => true,
recordCardsMined: (count) => calls.push(`cards:${count}`),
})(); })();
assert.deepEqual(mine.getMpvClient(), { connected: true }); assert.deepEqual(mine.getMpvClient(), { connected: true });
assert.deepEqual(mine.getPrimarySubtitle?.(), { assert.deepEqual(mine.getPrimarySubtitle?.(), {
@@ -81,16 +80,6 @@ test('anki action main deps builders map callbacks', async () => {
mpvClient: { connected: true }, mpvClient: { connected: true },
showMpvOsd: () => {}, showMpvOsd: () => {},
}); });
mine.recordCardsMined(1);
assert.deepEqual(calls, [ assert.deepEqual(calls, ['osd:x', 'update', 'fg:fg', 'trigger', 'audio:a', 'mark', 'mine:m']);
'osd:x',
'update',
'fg:fg',
'trigger',
'audio:a',
'mark',
'mine:m',
'cards:1',
]);
}); });
@@ -80,7 +80,6 @@ export function createBuildMineSentenceCardMainDepsHandler<TAnki, TMpv>(deps: {
primarySubtitle?: PrimarySubtitle; primarySubtitle?: PrimarySubtitle;
showMpvOsd: (text: string) => void; showMpvOsd: (text: string) => void;
}) => Promise<boolean>; }) => Promise<boolean>;
recordCardsMined: (count: number, noteIds?: number[]) => void;
}) { }) {
return () => ({ return () => ({
getAnkiIntegration: () => deps.getAnkiIntegration(), getAnkiIntegration: () => deps.getAnkiIntegration(),
@@ -95,6 +94,5 @@ export function createBuildMineSentenceCardMainDepsHandler<TAnki, TMpv>(deps: {
primarySubtitle?: PrimarySubtitle; primarySubtitle?: PrimarySubtitle;
showMpvOsd: (text: string) => void; showMpvOsd: (text: string) => void;
}) => deps.mineSentenceCardCore(options), }) => deps.mineSentenceCardCore(options),
recordCardsMined: (count: number, noteIds?: number[]) => deps.recordCardsMined(count, noteIds),
}); });
} }
+2 -4
View File
@@ -63,7 +63,7 @@ test('trigger and mark handlers delegate to core services', async () => {
assert.deepEqual(calls, ['osd:group', 'group-core', 'osd:mark', 'mark-core']); assert.deepEqual(calls, ['osd:group', 'group-core', 'osd:mark', 'mark-core']);
}); });
test('mine sentence handler records mined cards only when core returns true', async () => { test('mine sentence handler leaves card counting to card creation', async () => {
const calls: string[] = []; const calls: string[] = [];
const integration = {}; const integration = {};
const mpvClient = {}; const mpvClient = {};
@@ -78,14 +78,13 @@ test('mine sentence handler records mined cards only when core returns true', as
options.showMpvOsd('mine'); options.showMpvOsd('mine');
return created; return created;
}, },
recordCardsMined: (count) => calls.push(`cards:${count}`),
}); });
created = false; created = false;
await mineSentenceCard(); await mineSentenceCard();
created = true; created = true;
await mineSentenceCard(); await mineSentenceCard();
assert.deepEqual(calls, ['osd:mine', 'osd:mine', 'cards:1']); assert.deepEqual(calls, ['osd:mine', 'osd:mine']);
}); });
test('mine sentence handler forwards the canonical primary subtitle snapshot', async () => { test('mine sentence handler forwards the canonical primary subtitle snapshot', async () => {
@@ -99,7 +98,6 @@ test('mine sentence handler forwards the canonical primary subtitle snapshot', a
assert.equal(options.primarySubtitle, primarySubtitle); assert.equal(options.primarySubtitle, primarySubtitle);
return true; return true;
}, },
recordCardsMined: () => {},
}); });
await mineSentenceCard(); await mineSentenceCard();
+2 -5
View File
@@ -83,18 +83,15 @@ export function createMineSentenceCardHandler<TAnki, TMpv>(deps: {
primarySubtitle?: PrimarySubtitle; primarySubtitle?: PrimarySubtitle;
showMpvOsd: (text: string) => void; showMpvOsd: (text: string) => void;
}) => Promise<boolean>; }) => Promise<boolean>;
recordCardsMined: (count: number, noteIds?: number[]) => void;
}) { }) {
// Card creation records the mined card (with its note id) itself.
return async (): Promise<void> => { return async (): Promise<void> => {
const primarySubtitle = deps.getPrimarySubtitle?.(); const primarySubtitle = deps.getPrimarySubtitle?.();
const created = await deps.mineSentenceCardCore({ await deps.mineSentenceCardCore({
ankiIntegration: deps.getAnkiIntegration(), ankiIntegration: deps.getAnkiIntegration(),
mpvClient: deps.getMpvClient(), mpvClient: deps.getMpvClient(),
...(primarySubtitle ? { primarySubtitle } : {}), ...(primarySubtitle ? { primarySubtitle } : {}),
showMpvOsd: deps.showMpvOsd, showMpvOsd: deps.showMpvOsd,
}); });
if (created) {
deps.recordCardsMined(1);
}
}; };
} }
@@ -45,20 +45,17 @@ test('mining action main deps builders map callbacks', () => {
getCurrentSecondarySubText: () => 'sub', getCurrentSecondarySubText: () => 'sub',
showMpvOsd: (text) => calls.push(`mine-osd:${text}`), showMpvOsd: (text) => calls.push(`mine-osd:${text}`),
logError: (message) => calls.push(`err:${message}`), logError: (message) => calls.push(`err:${message}`),
onCardsMined: (count) => calls.push(`cards:${count}`),
handleMineSentenceDigitCore: () => calls.push('mine-digit'), handleMineSentenceDigitCore: () => calls.push('mine-digit'),
})(); })();
assert.equal(mineDigit.getCurrentSecondarySubText(), 'sub'); assert.equal(mineDigit.getCurrentSecondarySubText(), 'sub');
mineDigit.showMpvOsd('done'); mineDigit.showMpvOsd('done');
mineDigit.logError('bad', null); mineDigit.logError('bad', null);
mineDigit.onCardsMined(2);
mineDigit.handleMineSentenceDigitCore(2, { mineDigit.handleMineSentenceDigitCore(2, {
subtitleTimingTracker: { track: true }, subtitleTimingTracker: { track: true },
ankiIntegration: { enabled: true }, ankiIntegration: { enabled: true },
getCurrentSecondarySubText: () => 'sub', getCurrentSecondarySubText: () => 'sub',
showMpvOsd: () => {}, showMpvOsd: () => {},
logError: () => {}, logError: () => {},
onCardsMined: () => {},
}); });
assert.deepEqual(calls, [ assert.deepEqual(calls, [
@@ -70,7 +67,6 @@ test('mining action main deps builders map callbacks', () => {
'copy-current', 'copy-current',
'mine-osd:done', 'mine-osd:done',
'err:bad', 'err:bad',
'cards:2',
'mine-digit', 'mine-digit',
]); ]);
}); });
@@ -57,7 +57,6 @@ export function createBuildHandleMineSentenceDigitMainDepsHandler<
getCurrentSecondarySubText: () => string | undefined; getCurrentSecondarySubText: () => string | undefined;
showMpvOsd: (text: string) => void; showMpvOsd: (text: string) => void;
logError: (message: string, err: unknown) => void; logError: (message: string, err: unknown) => void;
onCardsMined: (count: number) => void;
handleMineSentenceDigitCore: ( handleMineSentenceDigitCore: (
count: number, count: number,
options: { options: {
@@ -66,7 +65,6 @@ export function createBuildHandleMineSentenceDigitMainDepsHandler<
getCurrentSecondarySubText: () => string | undefined; getCurrentSecondarySubText: () => string | undefined;
showMpvOsd: (text: string) => void; showMpvOsd: (text: string) => void;
logError: (message: string, err: unknown) => void; logError: (message: string, err: unknown) => void;
onCardsMined: (count: number) => void;
}, },
) => void; ) => void;
}) { }) {
@@ -76,7 +74,6 @@ export function createBuildHandleMineSentenceDigitMainDepsHandler<
getCurrentSecondarySubText: () => deps.getCurrentSecondarySubText(), getCurrentSecondarySubText: () => deps.getCurrentSecondarySubText(),
showMpvOsd: (text: string) => deps.showMpvOsd(text), showMpvOsd: (text: string) => deps.showMpvOsd(text),
logError: (message: string, err: unknown) => deps.logError(message, err), logError: (message: string, err: unknown) => deps.logError(message, err),
onCardsMined: (count: number) => deps.onCardsMined(count),
handleMineSentenceDigitCore: ( handleMineSentenceDigitCore: (
count: number, count: number,
options: { options: {
@@ -85,7 +82,6 @@ export function createBuildHandleMineSentenceDigitMainDepsHandler<
getCurrentSecondarySubText: () => string | undefined; getCurrentSecondarySubText: () => string | undefined;
showMpvOsd: (text: string) => void; showMpvOsd: (text: string) => void;
logError: (message: string, err: unknown) => void; logError: (message: string, err: unknown) => void;
onCardsMined: (count: number) => void;
}, },
) => deps.handleMineSentenceDigitCore(count, options), ) => deps.handleMineSentenceDigitCore(count, options),
}); });
+1 -3
View File
@@ -53,7 +53,6 @@ test('mine sentence digit handler forwards all dependencies', () => {
getCurrentSecondarySubText: () => 'secondary', getCurrentSecondarySubText: () => 'secondary',
showMpvOsd: (text) => calls.push(`osd:${text}`), showMpvOsd: (text) => calls.push(`osd:${text}`),
logError: (message) => calls.push(`err:${message}`), logError: (message) => calls.push(`err:${message}`),
onCardsMined: (count) => calls.push(`cards:${count}`),
handleMineSentenceDigitCore: (count, options) => { handleMineSentenceDigitCore: (count, options) => {
assert.equal(count, 4); assert.equal(count, 4);
assert.equal(options.subtitleTimingTracker, tracker); assert.equal(options.subtitleTimingTracker, tracker);
@@ -61,10 +60,9 @@ test('mine sentence digit handler forwards all dependencies', () => {
assert.equal(options.getCurrentSecondarySubText(), 'secondary'); assert.equal(options.getCurrentSecondarySubText(), 'secondary');
options.showMpvOsd('mine'); options.showMpvOsd('mine');
options.logError('boom', new Error('x')); options.logError('boom', new Error('x'));
options.onCardsMined(2);
}, },
}); });
handleMineSentenceDigit(4); handleMineSentenceDigit(4);
assert.deepEqual(calls, ['osd:mine', 'err:boom', 'cards:2']); assert.deepEqual(calls, ['osd:mine', 'err:boom']);
}); });
-3
View File
@@ -48,7 +48,6 @@ export function createHandleMineSentenceDigitHandler<
getCurrentSecondarySubText: () => string | undefined; getCurrentSecondarySubText: () => string | undefined;
showMpvOsd: (text: string) => void; showMpvOsd: (text: string) => void;
logError: (message: string, err: unknown) => void; logError: (message: string, err: unknown) => void;
onCardsMined: (count: number) => void;
handleMineSentenceDigitCore: ( handleMineSentenceDigitCore: (
count: number, count: number,
options: { options: {
@@ -57,7 +56,6 @@ export function createHandleMineSentenceDigitHandler<
getCurrentSecondarySubText: () => string | undefined; getCurrentSecondarySubText: () => string | undefined;
showMpvOsd: (text: string) => void; showMpvOsd: (text: string) => void;
logError: (message: string, err: unknown) => void; logError: (message: string, err: unknown) => void;
onCardsMined: (count: number) => void;
}, },
) => void; ) => void;
}) { }) {
@@ -68,7 +66,6 @@ export function createHandleMineSentenceDigitHandler<
getCurrentSecondarySubText: deps.getCurrentSecondarySubText, getCurrentSecondarySubText: deps.getCurrentSecondarySubText,
showMpvOsd: deps.showMpvOsd, showMpvOsd: deps.showMpvOsd,
logError: deps.logError, logError: deps.logError,
onCardsMined: deps.onCardsMined,
}); });
}; };
} }
@@ -0,0 +1,266 @@
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'] 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();
}
});
}
+31 -6
View File
@@ -27,8 +27,9 @@ export function OverviewTab({
onNavigateToSession, onNavigateToSession,
isActive = true, isActive = true,
}: OverviewTabProps) { }: OverviewTabProps) {
const { data, sessions, setSessions, loading, error } = useOverview(); const [refreshKey, setRefreshKey] = useState(0);
const { calendar, loading: calLoading } = useStreakCalendar(90); const { data, sessions, setSessions, loading, error } = useOverview(refreshKey);
const { calendar, loading: calLoading } = useStreakCalendar(90, refreshKey);
const [deleteError, setDeleteError] = useState<string | null>(null); const [deleteError, setDeleteError] = useState<string | null>(null);
const [deletingIds, setDeletingIds] = useState<Set<number>>(new Set()); const [deletingIds, setDeletingIds] = useState<Set<number>>(new Set());
const [knownWordsSummary, setKnownWordsSummary] = useState<{ const [knownWordsSummary, setKnownWordsSummary] = useState<{
@@ -49,7 +50,7 @@ export function OverviewTab({
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, []); }, [refreshKey]);
const handleDeleteSession = async (session: SessionSummary) => { const handleDeleteSession = async (session: SessionSummary) => {
if (!(await confirmSessionDelete())) return; if (!(await confirmSessionDelete())) return;
@@ -58,6 +59,7 @@ export function OverviewTab({
try { try {
await apiClient.deleteSession(session.sessionId); await apiClient.deleteSession(session.sessionId);
setSessions((prev) => prev.filter((s) => s.sessionId !== session.sessionId)); setSessions((prev) => prev.filter((s) => s.sessionId !== session.sessionId));
setRefreshKey((prev) => prev + 1);
} catch (err) { } catch (err) {
setDeleteError(err instanceof Error ? err.message : 'Failed to delete session.'); setDeleteError(err instanceof Error ? err.message : 'Failed to delete session.');
} finally { } finally {
@@ -82,6 +84,7 @@ export function OverviewTab({
await apiClient.deleteSessions(ids); await apiClient.deleteSessions(ids);
const idSet = new Set(ids); const idSet = new Set(ids);
setSessions((prev) => prev.filter((s) => !idSet.has(s.sessionId))); setSessions((prev) => prev.filter((s) => !idSet.has(s.sessionId)));
setRefreshKey((prev) => prev + 1);
} catch (err) { } catch (err) {
setDeleteError(err instanceof Error ? err.message : 'Failed to delete sessions.'); setDeleteError(err instanceof Error ? err.message : 'Failed to delete sessions.');
} finally { } finally {
@@ -108,6 +111,7 @@ export function OverviewTab({
await apiClient.deleteSessions(ids); await apiClient.deleteSessions(ids);
const idSet = new Set(ids); const idSet = new Set(ids);
setSessions((prev) => prev.filter((s) => !idSet.has(s.sessionId))); setSessions((prev) => prev.filter((s) => !idSet.has(s.sessionId)));
setRefreshKey((prev) => prev + 1);
} catch (err) { } catch (err) {
setDeleteError(err instanceof Error ? err.message : 'Failed to delete sessions.'); setDeleteError(err instanceof Error ? err.message : 'Failed to delete sessions.');
} finally { } finally {
@@ -119,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);
@@ -129,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">
+2 -2
View File
@@ -2,7 +2,7 @@ import { useState, useEffect } from 'react';
import { getStatsClient } from './useStatsApi'; import { getStatsClient } from './useStatsApi';
import type { OverviewData, SessionSummary } from '../types/stats'; import type { OverviewData, SessionSummary } from '../types/stats';
export function useOverview() { export function useOverview(refreshKey = 0) {
const [data, setData] = useState<OverviewData | null>(null); const [data, setData] = useState<OverviewData | null>(null);
const [sessions, setSessions] = useState<SessionSummary[]>([]); const [sessions, setSessions] = useState<SessionSummary[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -30,7 +30,7 @@ export function useOverview() {
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, []); }, [refreshKey]);
return { data, sessions, setSessions, loading, error }; return { data, sessions, setSessions, loading, error };
} }
+4 -2
View File
@@ -2,13 +2,15 @@ import { useState, useEffect } from 'react';
import { getStatsClient } from './useStatsApi'; import { getStatsClient } from './useStatsApi';
import type { StreakCalendarDay } from '../types/stats'; import type { StreakCalendarDay } from '../types/stats';
export function useStreakCalendar(days = 90) { export function useStreakCalendar(days = 90, refreshKey = 0) {
const [calendar, setCalendar] = useState<StreakCalendarDay[]>([]); const [calendar, setCalendar] = useState<StreakCalendarDay[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
setLoading(true);
setError(null);
getStatsClient() getStatsClient()
.getStreakCalendar(days) .getStreakCalendar(days)
.then((data) => { .then((data) => {
@@ -23,7 +25,7 @@ export function useStreakCalendar(days = 90) {
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [days]); }, [days, refreshKey]);
return { calendar, loading, error }; return { calendar, loading, error };
} }