feat: show mining frame toast on OSD and speed up stats session deletes

- Flash mined-frame screenshot as in-overlay image toast for OSD card notifications
- Batch cover art from stored DB blobs via POST /api/stats/covers (no extra AniList fetches)
- Show delete progress toast on stats home and sessions pages
- Refresh only affected rollups on session delete instead of full rebuild
- Rebuild lifetime summaries with aggregate SQL CTEs instead of per-session loop
This commit is contained in:
2026-06-04 22:00:51 -07:00
parent ea79e331fa
commit b343f1cf5c
45 changed files with 1199 additions and 209 deletions
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: stats
- Retried and batched stats home cover images from stored DB art so AniList art appears without extra AniList lookups or a full page refresh.
+4
View File
@@ -0,0 +1,4 @@
type: added
area: overlay
- Show a screenshot of the mined frame as an in-overlay image toast alongside the OSD card-mining notification, matching the picture already shown on system notifications.
@@ -0,0 +1,4 @@
type: added
area: stats
- Showed a progress indicator while sessions are being deleted on the home and sessions pages, so long batch deletes give visible feedback instead of appearing to do nothing.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: stats
- Sped up deleting stats sessions by refreshing only affected rollups and rebuilding lifetime summaries with aggregate SQL.
+2
View File
@@ -220,6 +220,8 @@ Animated AVIF requires an AV1 encoder (`libaom-av1`, `libsvtav1`, or `librav1e`)
}
```
`notificationType` controls how a mined-card confirmation is shown: `osd` draws an on-screen message over the video, `system` posts a desktop notification, `both` does both, and `none` stays silent. The `osd` (and `both`) path also flashes a small screenshot of the mined frame in the overlay, matching the picture shown on the system notification.
`overwriteAudio` applies to automatic card updates and duplicate-card enrichment. Manual clipboard subtitle updates (`Ctrl/Cmd+C`, then `Ctrl/Cmd+V`) always replace generated sentence audio, while leaving the word audio field unchanged.
## AI Translation
+41
View File
@@ -406,6 +406,47 @@ test('AnkiIntegration marks partial update notifications as failures in OSD mode
assert.deepEqual(osdMessages, ['x Updated card: taberu (image failed)']);
});
test('AnkiIntegration emits a mining image data URL to the overlay for OSD notifications', async () => {
const osdMessages: string[] = [];
const overlayImages: string[] = [];
const integration = new AnkiIntegration(
{
behavior: {
notificationType: 'osd',
},
},
{} as never,
{ currentVideoPath: '/tmp/video.mkv', currentTimePos: 12 } as never,
(text) => {
osdMessages.push(text);
},
);
integration.setMiningImageOverlayCallback((image) => {
overlayImages.push(image);
});
const internals = integration as unknown as {
mediaGenerator: {
generateNotificationIcon: (path: string, timestamp: number) => Promise<Buffer>;
};
showNotification: (
noteId: number,
label: string | number,
errorSuffix?: string,
) => Promise<void>;
};
internals.mediaGenerator = {
generateNotificationIcon: async () => Buffer.from('frame-bytes'),
} as never;
await internals.showNotification(7, 'taberu');
assert.deepEqual(osdMessages, ['✓ Updated card: taberu']);
assert.deepEqual(overlayImages, [
`data:image/png;base64,${Buffer.from('frame-bytes').toString('base64')}`,
]);
});
test('FieldGroupingMergeCollaborator keeps SentenceAudio grouped without overwriting ExpressionAudio', async () => {
const collaborator = createFieldGroupingMergeCollaborator();
+44 -25
View File
@@ -148,6 +148,7 @@ export class AnkiIntegration {
private runtime: AnkiIntegrationRuntime;
private aiConfig: AiConfig;
private recordCardsMinedCallback: ((count: number, noteIds?: number[]) => void) | null = null;
private miningImageOverlayCallback: ((image: string) => void) | null = null;
private knownWordCacheUpdatedCallback: (() => void) | null = null;
private consumeSubtitleMiningContextCallback: (() => SubtitleMiningContext | null) | null = null;
private noteIdRedirects = new Map<number, number>();
@@ -1018,40 +1019,54 @@ export class AnkiIntegration {
: `Updated card: ${label}`;
const type = this.config.behavior?.notificationType || 'osd';
const wantsOsd = type === 'osd' || type === 'both';
const wantsSystem =
(type === 'system' || type === 'both') && this.notificationCallback !== null;
const wantsOverlayImage = wantsOsd && this.miningImageOverlayCallback !== null;
if (type === 'osd' || type === 'both') {
if (wantsOsd) {
this.showUpdateResult(message, errorSuffix === undefined);
} else {
this.clearUpdateProgress();
}
if ((type === 'system' || type === 'both') && this.notificationCallback) {
let notificationIconPath: string | undefined;
if (!wantsSystem && !wantsOverlayImage) {
return;
}
if (this.mpvClient && this.mpvClient.currentVideoPath) {
try {
const timestamp = this.mpvClient.currentTimePos || 0;
const notificationIconSource = await resolveMediaGenerationInputPath(
this.mpvClient,
'video',
);
if (!notificationIconSource) {
throw new Error('No media source available for notification icon');
}
const iconBuffer = await this.mediaGenerator.generateNotificationIcon(
notificationIconSource,
timestamp,
);
if (iconBuffer && iconBuffer.length > 0) {
notificationIconPath = this.mediaGenerator.writeNotificationIconToFile(
iconBuffer,
noteId,
);
}
} catch (err) {
log.warn('Failed to generate notification icon:', (err as Error).message);
// Generate the frame screenshot once and reuse it for the system notification
// icon and the in-overlay image toast.
let iconBuffer: Buffer | undefined;
if (this.mpvClient && this.mpvClient.currentVideoPath) {
try {
const timestamp = this.mpvClient.currentTimePos || 0;
const notificationIconSource = await resolveMediaGenerationInputPath(
this.mpvClient,
'video',
);
if (!notificationIconSource) {
throw new Error('No media source available for notification icon');
}
const buffer = await this.mediaGenerator.generateNotificationIcon(
notificationIconSource,
timestamp,
);
if (buffer && buffer.length > 0) {
iconBuffer = buffer;
}
} catch (err) {
log.warn('Failed to generate notification icon:', (err as Error).message);
}
}
if (wantsOverlayImage && iconBuffer && this.miningImageOverlayCallback) {
this.miningImageOverlayCallback(`data:image/png;base64,${iconBuffer.toString('base64')}`);
}
if (wantsSystem && this.notificationCallback) {
const notificationIconPath = iconBuffer
? this.mediaGenerator.writeNotificationIconToFile(iconBuffer, noteId)
: undefined;
this.notificationCallback('Anki Card Updated', {
body: message,
@@ -1364,6 +1379,10 @@ export class AnkiIntegration {
this.knownWordCacheUpdatedCallback = callback;
}
setMiningImageOverlayCallback(callback: ((image: string) => void) | null): void {
this.miningImageOverlayCallback = callback;
}
setSubtitleMiningContextConsumer(callback: (() => SubtitleMiningContext | null) | null): void {
this.consumeSubtitleMiningContextCallback = callback;
}
@@ -833,6 +833,56 @@ describe('stats server API routes', () => {
assert.equal(res.status, 404);
});
it('POST /api/stats/covers batches stored cover art without fetching missing art', async () => {
let ensureCoverArtCalls = 0;
const app = createStatsApp(
createMockTracker({
getCoverArt: async (videoId: number) =>
videoId === 7
? {
videoId,
anilistId: null,
coverUrl: null,
coverBlob: Buffer.from([0x89, 0x50]),
titleRomaji: null,
titleEnglish: null,
episodesTotal: null,
fetchedAtMs: Date.now(),
}
: null,
ensureCoverArt: async () => {
ensureCoverArtCalls += 1;
return true;
},
}),
);
const res = await app.request('/api/stats/covers', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ animeIds: [1, 99999], videoIds: [7, 99999] }),
});
assert.equal(res.status, 200);
assert.deepEqual(await res.json(), {
anime: {
1: {
contentType: 'image/jpeg',
dataUrl: 'data:image/jpeg;base64,/9j/2Q==',
},
99999: null,
},
media: {
7: {
contentType: 'image/jpeg',
dataUrl: 'data:image/jpeg;base64,iVA=',
},
99999: null,
},
});
assert.equal(ensureCoverArtCalls, 0);
});
it('GET /api/stats/anime/:animeId/words returns top words for an anime', async () => {
let seenArgs: unknown[] = [];
const app = createStatsApp(
@@ -605,6 +605,79 @@ test('split maintenance helpers update anime metadata and watched state', () =>
}
});
test('deleteSessions refreshes only rollups affected by deleted sessions', () => {
const { db, dbPath } = createDb();
try {
const keepVideoId = getOrCreateVideoRecord(db, 'local:/tmp/rollup-keep.mkv', {
canonicalTitle: 'Rollup Keep',
sourcePath: '/tmp/rollup-keep.mkv',
sourceUrl: null,
sourceType: SOURCE_TYPE_LOCAL,
});
const dropVideoId = getOrCreateVideoRecord(db, 'local:/tmp/rollup-drop.mkv', {
canonicalTitle: 'Rollup Drop',
sourcePath: '/tmp/rollup-drop.mkv',
sourceUrl: null,
sourceType: SOURCE_TYPE_LOCAL,
});
const keepStartedAtMs = 1_700_000_000_000;
const dropStartedAtMs = 1_700_086_400_000;
const keepSessionId = startSessionRecord(db, keepVideoId, keepStartedAtMs).sessionId;
const dropSessionId = startSessionRecord(db, dropVideoId, dropStartedAtMs).sessionId;
finalizeSessionMetrics(db, keepSessionId, keepStartedAtMs, {
activeWatchedMs: 30_000,
cardsMined: 1,
});
finalizeSessionMetrics(db, dropSessionId, dropStartedAtMs, {
activeWatchedMs: 60_000,
cardsMined: 2,
});
const keepDay = getLocalEpochDay(db, keepStartedAtMs);
const dropDay = getLocalEpochDay(db, dropStartedAtMs);
const keepMonth = 202311;
const dropMonth = 202311;
const insertDaily = db.prepare(`
INSERT INTO imm_daily_rollups (
rollup_day, video_id, total_sessions, total_active_min, total_lines_seen,
total_tokens_seen, total_cards, CREATED_DATE, LAST_UPDATE_DATE
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
const insertMonthly = db.prepare(`
INSERT INTO imm_monthly_rollups (
rollup_month, video_id, total_sessions, total_active_min, total_lines_seen,
total_tokens_seen, total_cards, CREATED_DATE, LAST_UPDATE_DATE
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
insertDaily.run(keepDay, keepVideoId, 1, 0.5, 3, 6, 1, keepStartedAtMs, keepStartedAtMs);
insertDaily.run(dropDay, dropVideoId, 1, 1, 3, 6, 2, dropStartedAtMs, dropStartedAtMs);
insertMonthly.run(keepMonth, keepVideoId, 1, 0.5, 3, 6, 1, keepStartedAtMs, keepStartedAtMs);
insertMonthly.run(dropMonth, dropVideoId, 1, 1, 3, 6, 2, dropStartedAtMs, dropStartedAtMs);
deleteSessions(db, [dropSessionId]);
const dailyRows = db
.prepare('SELECT rollup_day, video_id, total_cards FROM imm_daily_rollups ORDER BY video_id')
.all() as Array<{ rollup_day: number; video_id: number; total_cards: number }>;
const monthlyRows = db
.prepare(
'SELECT rollup_month, video_id, total_cards FROM imm_monthly_rollups ORDER BY video_id',
)
.all() as Array<{ rollup_month: number; video_id: number; total_cards: number }>;
assert.deepEqual(dailyRows, [{ rollup_day: keepDay, video_id: keepVideoId, total_cards: 1 }]);
assert.deepEqual(monthlyRows, [
{ rollup_month: keepMonth, video_id: keepVideoId, total_cards: 1 },
]);
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
test('split maintenance helpers delete multiple sessions and whole videos with dependent rows', () => {
const { db, dbPath, stmts } = createDb();
+168 -44
View File
@@ -60,6 +60,34 @@ interface RetainedSessionRow {
mediaBufferEvents: number;
}
const RETAINED_SESSION_METRICS_CTE = `
retained_sessions AS (
SELECT
s.session_id,
s.video_id,
v.anime_id,
s.started_at_ms,
s.ended_at_ms,
MAX(COALESCE(t.active_watched_ms, s.active_watched_ms, 0), 0) AS active_ms,
MAX(COALESCE(t.cards_mined, s.cards_mined, 0), 0) AS cards_mined,
MAX(COALESCE(t.lines_seen, s.lines_seen, 0), 0) AS lines_seen,
MAX(COALESCE(t.tokens_seen, s.tokens_seen, 0), 0) AS tokens_seen,
CASE WHEN v.watched > 0 THEN 1 ELSE 0 END AS completed
FROM imm_sessions s
JOIN imm_videos v
ON v.video_id = s.video_id
LEFT JOIN imm_session_telemetry t
ON t.telemetry_id = (
SELECT telemetry_id
FROM imm_session_telemetry
WHERE session_id = s.session_id
ORDER BY sample_ms DESC, telemetry_id DESC
LIMIT 1
)
WHERE s.ended_at_ms IS NOT NULL
)
`;
function hasRetainedPriorSession(
db: DatabaseSync,
videoId: number,
@@ -154,54 +182,150 @@ function rebuildLifetimeSummariesInternal(
db: DatabaseSync,
rebuiltAtMs: number,
): LifetimeRebuildSummary {
const rows = db
.prepare(
`
SELECT
session_id AS sessionId,
video_id AS videoId,
started_at_ms AS startedAtMs,
ended_at_ms AS endedAtMs,
ended_media_ms AS lastMediaMs,
total_watched_ms AS totalWatchedMs,
active_watched_ms AS activeWatchedMs,
lines_seen AS linesSeen,
tokens_seen AS tokensSeen,
cards_mined AS cardsMined,
lookup_count AS lookupCount,
lookup_hits AS lookupHits,
yomitan_lookup_count AS yomitanLookupCount,
pause_count AS pauseCount,
pause_ms AS pauseMs,
seek_forward_count AS seekForwardCount,
seek_backward_count AS seekBackwardCount,
media_buffer_events AS mediaBufferEvents
FROM imm_sessions
WHERE ended_at_ms IS NOT NULL
ORDER BY started_at_ms ASC, session_id ASC
`,
)
.all() as Array<
Omit<RetainedSessionRow, 'startedAtMs' | 'endedAtMs' | 'lastMediaMs'> & {
startedAtMs: number | string;
endedAtMs: number | string;
lastMediaMs: number | string | null;
}
>;
const sessions = rows.map((row) => ({
...row,
startedAtMs: row.startedAtMs,
endedAtMs: row.endedAtMs,
lastMediaMs: row.lastMediaMs === null ? null : Number(row.lastMediaMs),
})) as RetainedSessionRow[];
const rebuiltAtDbMs = toDbTimestamp(rebuiltAtMs);
const appliedSessions = Number(
(
db
.prepare('SELECT COUNT(*) AS total FROM imm_sessions WHERE ended_at_ms IS NOT NULL')
.get() as { total: number }
).total,
);
resetLifetimeSummaries(db, rebuiltAtMs);
for (const session of sessions) {
applySessionLifetimeSummary(db, toRebuildSessionState(session), session.endedAtMs);
}
db.prepare(
`
INSERT INTO imm_lifetime_applied_sessions (
session_id,
applied_at_ms,
CREATED_DATE,
LAST_UPDATE_DATE
)
SELECT
session_id,
ended_at_ms,
?,
?
FROM imm_sessions
WHERE ended_at_ms IS NOT NULL
`,
).run(rebuiltAtDbMs, rebuiltAtDbMs);
db.prepare(
`
WITH ${RETAINED_SESSION_METRICS_CTE}
INSERT INTO imm_lifetime_media (
video_id,
total_sessions,
total_active_ms,
total_cards,
total_lines_seen,
total_tokens_seen,
completed,
first_watched_ms,
last_watched_ms,
CREATED_DATE,
LAST_UPDATE_DATE
)
SELECT
video_id,
COUNT(*) AS total_sessions,
COALESCE(SUM(active_ms), 0) AS total_active_ms,
COALESCE(SUM(cards_mined), 0) AS total_cards,
COALESCE(SUM(lines_seen), 0) AS total_lines_seen,
COALESCE(SUM(tokens_seen), 0) AS total_tokens_seen,
MAX(completed) AS completed,
MIN(started_at_ms) AS first_watched_ms,
MAX(ended_at_ms) AS last_watched_ms,
? AS CREATED_DATE,
? AS LAST_UPDATE_DATE
FROM retained_sessions
GROUP BY video_id
`,
).run(rebuiltAtDbMs, rebuiltAtDbMs);
db.prepare(
`
WITH ${RETAINED_SESSION_METRICS_CTE}
INSERT INTO imm_lifetime_anime (
anime_id,
total_sessions,
total_active_ms,
total_cards,
total_lines_seen,
total_tokens_seen,
episodes_started,
episodes_completed,
first_watched_ms,
last_watched_ms,
CREATED_DATE,
LAST_UPDATE_DATE
)
SELECT
anime_id,
COUNT(*) AS total_sessions,
COALESCE(SUM(active_ms), 0) AS total_active_ms,
COALESCE(SUM(cards_mined), 0) AS total_cards,
COALESCE(SUM(lines_seen), 0) AS total_lines_seen,
COALESCE(SUM(tokens_seen), 0) AS total_tokens_seen,
COUNT(DISTINCT video_id) AS episodes_started,
COUNT(DISTINCT CASE WHEN completed > 0 THEN video_id END) AS episodes_completed,
MIN(started_at_ms) AS first_watched_ms,
MAX(ended_at_ms) AS last_watched_ms,
? AS CREATED_DATE,
? AS LAST_UPDATE_DATE
FROM retained_sessions
WHERE anime_id IS NOT NULL
GROUP BY anime_id
`,
).run(rebuiltAtDbMs, rebuiltAtDbMs);
db.prepare(
`
WITH ${RETAINED_SESSION_METRICS_CTE},
anime_completion AS (
SELECT
rs.anime_id,
MAX(a.episodes_total) AS episodes_total,
COUNT(DISTINCT CASE WHEN rs.completed > 0 THEN rs.video_id END) AS completed_videos
FROM retained_sessions rs
JOIN imm_anime a
ON a.anime_id = rs.anime_id
WHERE rs.anime_id IS NOT NULL
GROUP BY rs.anime_id
)
UPDATE imm_lifetime_global
SET
total_sessions = (SELECT COUNT(*) FROM retained_sessions),
total_active_ms = (SELECT COALESCE(SUM(active_ms), 0) FROM retained_sessions),
total_cards = (SELECT COALESCE(SUM(cards_mined), 0) FROM retained_sessions),
active_days = (
SELECT COUNT(DISTINCT CAST(
julianday(CAST(started_at_ms AS REAL) / 1000, 'unixepoch', 'localtime') - 2440587.5
AS INTEGER
))
FROM retained_sessions
),
episodes_started = (SELECT COUNT(DISTINCT video_id) FROM retained_sessions),
episodes_completed = (
SELECT COUNT(DISTINCT CASE WHEN completed > 0 THEN video_id END)
FROM retained_sessions
),
anime_completed = (
SELECT COUNT(*)
FROM anime_completion
WHERE episodes_total IS NOT NULL
AND episodes_total > 0
AND completed_videos >= episodes_total
),
last_rebuilt_ms = ?,
LAST_UPDATE_DATE = ?
WHERE global_id = 1
`,
).run(rebuiltAtDbMs, rebuiltAtDbMs);
return {
appliedSessions: sessions.length,
appliedSessions,
rebuiltAtMs,
};
}
@@ -1,6 +1,6 @@
import type { DatabaseSync } from './sqlite';
import { nowMs } from './time';
import { subtractDbTimestamp, toDbTimestamp } from './query-shared';
import { makePlaceholders, subtractDbTimestamp, toDbTimestamp } from './query-shared';
const ROLLUP_STATE_KEY = 'last_rollup_sample_ms';
const DAILY_MS = 86_400_000;
@@ -20,6 +20,12 @@ interface RollupTelemetryResult {
maxSampleMs: number | null;
}
export interface RollupGroup {
rollupDay: number;
rollupMonth: number;
videoId: number;
}
interface RawRetentionResult {
deletedSessionEvents: number;
deletedTelemetryRows: number;
@@ -164,6 +170,26 @@ function upsertDailyRollupsForGroups(
}
const upsertStmt = db.prepare(`
WITH matching_sessions AS (
SELECT *
FROM imm_sessions
WHERE CAST(julianday(CAST(started_at_ms AS REAL) / 1000, 'unixepoch', 'localtime') - 2440587.5 AS INTEGER) = ?
AND video_id = ?
),
session_metrics AS (
SELECT
t.session_id,
MAX(t.active_watched_ms) AS max_active_ms,
MAX(t.lines_seen) AS max_lines,
MAX(t.tokens_seen) AS max_tokens,
MAX(t.cards_mined) AS max_cards,
MAX(t.lookup_count) AS max_lookups,
MAX(t.lookup_hits) AS max_hits
FROM imm_session_telemetry t
JOIN matching_sessions s
ON s.session_id = t.session_id
GROUP BY t.session_id
)
INSERT INTO imm_daily_rollups (
rollup_day, video_id, total_sessions, total_active_min, total_lines_seen,
total_tokens_seen, total_cards, cards_per_hour,
@@ -197,20 +223,8 @@ function upsertDailyRollupsForGroups(
END AS lookup_hit_rate,
? AS CREATED_DATE,
? AS LAST_UPDATE_DATE
FROM imm_sessions s
LEFT JOIN (
SELECT
t.session_id,
MAX(t.active_watched_ms) AS max_active_ms,
MAX(t.lines_seen) AS max_lines,
MAX(t.tokens_seen) AS max_tokens,
MAX(t.cards_mined) AS max_cards,
MAX(t.lookup_count) AS max_lookups,
MAX(t.lookup_hits) AS max_hits
FROM imm_session_telemetry t
GROUP BY t.session_id
) sm ON s.session_id = sm.session_id
WHERE CAST(julianday(s.started_at_ms / 1000, 'unixepoch', 'localtime') - 2440587.5 AS INTEGER) = ? AND s.video_id = ?
FROM matching_sessions s
LEFT JOIN session_metrics sm ON s.session_id = sm.session_id
GROUP BY rollup_day, s.video_id
ON CONFLICT (rollup_day, video_id) DO UPDATE SET
total_sessions = excluded.total_sessions,
@@ -226,7 +240,7 @@ function upsertDailyRollupsForGroups(
`);
for (const { rollupDay, videoId } of groups) {
upsertStmt.run(rollupNowMs, rollupNowMs, rollupDay, videoId);
upsertStmt.run(rollupDay, videoId, rollupNowMs, rollupNowMs);
}
}
@@ -240,6 +254,24 @@ function upsertMonthlyRollupsForGroups(
}
const upsertStmt = db.prepare(`
WITH matching_sessions AS (
SELECT *
FROM imm_sessions
WHERE CAST(strftime('%Y%m', CAST(started_at_ms AS REAL) / 1000, 'unixepoch', 'localtime') AS INTEGER) = ?
AND video_id = ?
),
session_metrics AS (
SELECT
t.session_id,
MAX(t.active_watched_ms) AS max_active_ms,
MAX(t.lines_seen) AS max_lines,
MAX(t.tokens_seen) AS max_tokens,
MAX(t.cards_mined) AS max_cards
FROM imm_session_telemetry t
JOIN matching_sessions s
ON s.session_id = t.session_id
GROUP BY t.session_id
)
INSERT INTO imm_monthly_rollups (
rollup_month, video_id, total_sessions, total_active_min, total_lines_seen,
total_tokens_seen, total_cards, CREATED_DATE, LAST_UPDATE_DATE
@@ -254,18 +286,8 @@ function upsertMonthlyRollupsForGroups(
COALESCE(SUM(COALESCE(sm.max_cards, s.cards_mined)), 0) AS total_cards,
? AS CREATED_DATE,
? AS LAST_UPDATE_DATE
FROM imm_sessions s
LEFT JOIN (
SELECT
t.session_id,
MAX(t.active_watched_ms) AS max_active_ms,
MAX(t.lines_seen) AS max_lines,
MAX(t.tokens_seen) AS max_tokens,
MAX(t.cards_mined) AS max_cards
FROM imm_session_telemetry t
GROUP BY t.session_id
) sm ON s.session_id = sm.session_id
WHERE CAST(strftime('%Y%m', s.started_at_ms / 1000, 'unixepoch', 'localtime') AS INTEGER) = ? AND s.video_id = ?
FROM matching_sessions s
LEFT JOIN session_metrics sm ON s.session_id = sm.session_id
GROUP BY rollup_month, s.video_id
ON CONFLICT (rollup_month, video_id) DO UPDATE SET
total_sessions = excluded.total_sessions,
@@ -278,10 +300,82 @@ function upsertMonthlyRollupsForGroups(
`);
for (const { rollupMonth, videoId } of groups) {
upsertStmt.run(rollupNowMs, rollupNowMs, rollupMonth, videoId);
upsertStmt.run(rollupMonth, videoId, rollupNowMs, rollupNowMs);
}
}
export function getRollupGroupsForSessions(db: DatabaseSync, sessionIds: number[]): RollupGroup[] {
if (sessionIds.length === 0) {
return [];
}
const placeholders = makePlaceholders(sessionIds);
const rows = db
.prepare(
`
SELECT DISTINCT
CAST(julianday(CAST(started_at_ms AS REAL) / 1000, 'unixepoch', 'localtime') - 2440587.5 AS INTEGER) AS rollup_day,
CAST(strftime('%Y%m', CAST(started_at_ms AS REAL) / 1000, 'unixepoch', 'localtime') AS INTEGER) AS rollup_month,
video_id
FROM imm_sessions
WHERE session_id IN (${placeholders})
UNION
SELECT DISTINCT
CAST(CAST(started_at_ms AS REAL) / 86400000 AS INTEGER) AS rollup_day,
CAST(strftime('%Y%m', CAST(started_at_ms AS REAL) / 1000, 'unixepoch') AS INTEGER) AS rollup_month,
video_id
FROM imm_sessions
WHERE session_id IN (${placeholders})
`,
)
.all(...sessionIds, ...sessionIds) as RollupGroupRow[];
return rows.map((row) => ({
rollupDay: row.rollup_day,
rollupMonth: row.rollup_month,
videoId: row.video_id,
}));
}
export function refreshRollupsForGroupsInTransaction(
db: DatabaseSync,
groups: RollupGroup[],
): void {
if (groups.length === 0) {
return;
}
const rollupNowMs = toDbTimestamp(nowMs());
const dailyGroups = dedupeGroups(
groups.map((group) => ({
rollupDay: group.rollupDay,
videoId: group.videoId,
})),
);
const monthlyGroups = dedupeGroups(
groups.map((group) => ({
rollupMonth: group.rollupMonth,
videoId: group.videoId,
})),
);
const deleteDailyStmt = db.prepare(
'DELETE FROM imm_daily_rollups WHERE rollup_day = ? AND video_id = ?',
);
const deleteMonthlyStmt = db.prepare(
'DELETE FROM imm_monthly_rollups WHERE rollup_month = ? AND video_id = ?',
);
for (const { rollupDay, videoId } of dailyGroups) {
deleteDailyStmt.run(rollupDay, videoId);
}
for (const { rollupMonth, videoId } of monthlyGroups) {
deleteMonthlyStmt.run(rollupMonth, videoId);
}
upsertDailyRollupsForGroups(db, dailyGroups, rollupNowMs);
upsertMonthlyRollupsForGroups(db, monthlyGroups, rollupNowMs);
}
function getAffectedRollupGroups(
db: DatabaseSync,
lastRollupSampleMs: number | string,
@@ -2,7 +2,7 @@ import { createHash } from 'node:crypto';
import type { DatabaseSync } from './sqlite';
import { buildCoverBlobReference, normalizeCoverBlobBytes } from './storage';
import { rebuildLifetimeSummariesInTransaction } from './lifetime';
import { rebuildRollupsInTransaction } from './maintenance';
import { getRollupGroupsForSessions, refreshRollupsForGroupsInTransaction } from './maintenance';
import { nowMs } from './time';
import { PartOfSpeech, type MergedToken } from '../../../types';
import { shouldExcludeTokenFromVocabularyPersistence } from '../tokenizer/annotation-stage';
@@ -474,13 +474,14 @@ export function deleteSession(db: DatabaseSync, sessionId: number): void {
const sessionIds = [sessionId];
const affectedWordIds = getAffectedWordIdsForSessions(db, sessionIds);
const affectedKanjiIds = getAffectedKanjiIdsForSessions(db, sessionIds);
const affectedRollupGroups = getRollupGroupsForSessions(db, sessionIds);
db.exec('BEGIN IMMEDIATE');
try {
deleteSessionsByIds(db, sessionIds);
refreshLexicalAggregates(db, affectedWordIds, affectedKanjiIds);
rebuildLifetimeSummariesInTransaction(db);
rebuildRollupsInTransaction(db);
refreshRollupsForGroupsInTransaction(db, affectedRollupGroups);
db.exec('COMMIT');
} catch (error) {
db.exec('ROLLBACK');
@@ -492,13 +493,14 @@ export function deleteSessions(db: DatabaseSync, sessionIds: number[]): void {
if (sessionIds.length === 0) return;
const affectedWordIds = getAffectedWordIdsForSessions(db, sessionIds);
const affectedKanjiIds = getAffectedKanjiIdsForSessions(db, sessionIds);
const affectedRollupGroups = getRollupGroupsForSessions(db, sessionIds);
db.exec('BEGIN IMMEDIATE');
try {
deleteSessionsByIds(db, sessionIds);
refreshLexicalAggregates(db, affectedWordIds, affectedKanjiIds);
rebuildLifetimeSummariesInTransaction(db);
rebuildRollupsInTransaction(db);
refreshRollupsForGroupsInTransaction(db, affectedRollupGroups);
db.exec('COMMIT');
} catch (error) {
db.exec('ROLLBACK');
@@ -536,7 +538,6 @@ export function deleteVideo(db: DatabaseSync, videoId: number): void {
db.prepare('DELETE FROM imm_videos WHERE video_id = ?').run(videoId);
refreshLexicalAggregates(db, affectedWordIds, affectedKanjiIds);
rebuildLifetimeSummariesInTransaction(db);
rebuildRollupsInTransaction(db);
db.exec('COMMIT');
} catch (error) {
db.exec('ROLLBACK');
+56
View File
@@ -27,6 +27,16 @@ type StatsExcludedWordPayload = {
reading: string;
};
type StatsCoverImagePayload = {
contentType: 'image/jpeg';
dataUrl: string;
} | null;
type StatsCoverBatchBody = {
animeIds?: unknown;
videoIds?: unknown;
};
function parseIntQuery(raw: string | undefined, fallback: number, maxLimit?: number): number {
if (raw === undefined) return fallback;
const n = Number(raw);
@@ -73,6 +83,31 @@ function parseExcludedWordsBody(body: unknown): StatsExcludedWordPayload[] | nul
return words;
}
function parsePositiveIdList(raw: unknown, maxItems = 100): number[] {
if (!Array.isArray(raw)) return [];
const ids = new Set<number>();
for (const rawId of raw) {
const id = typeof rawId === 'number' ? rawId : typeof rawId === 'string' ? Number(rawId) : NaN;
if (Number.isFinite(id) && id > 0) {
ids.add(Math.floor(id));
if (ids.size >= maxItems) break;
}
}
return Array.from(ids).sort((a, b) => a - b);
}
function coverImagePayload(
art: { coverBlob?: Uint8Array | null } | null | undefined,
): StatsCoverImagePayload {
if (!art?.coverBlob) return null;
return {
contentType: 'image/jpeg',
dataUrl: `data:image/jpeg;base64,${Buffer.from(art.coverBlob).toString('base64')}`,
};
}
function resolveStatsNoteFieldName(
noteInfo: StatsServerNoteInfo,
...preferredNames: (string | undefined)[]
@@ -707,6 +742,27 @@ export function createStatsApp(
return c.json({ ok: true });
});
app.post('/api/stats/covers', async (c) => {
const body = (await c.req.json().catch(() => null)) as StatsCoverBatchBody | null;
const animeIds = parsePositiveIdList(body?.animeIds);
const videoIds = parsePositiveIdList(body?.videoIds);
const anime: Record<string, StatsCoverImagePayload> = {};
const media: Record<string, StatsCoverImagePayload> = {};
await Promise.all(
animeIds.map(async (animeId) => {
anime[String(animeId)] = coverImagePayload(await tracker.getAnimeCoverArt(animeId));
}),
);
await Promise.all(
videoIds.map(async (videoId) => {
media[String(videoId)] = coverImagePayload(await tracker.getCoverArt(videoId));
}),
);
return c.json({ anime, media });
});
app.get('/api/stats/anime/:animeId/cover', async (c) => {
const animeId = parseIntQuery(c.req.param('animeId'), 0);
if (animeId <= 0) return c.body(null, 404);
+6
View File
@@ -3266,6 +3266,10 @@ function broadcastToOverlayWindows(channel: string, ...args: unknown[]): void {
overlayManager.broadcastToOverlayWindows(channel, ...args);
}
function broadcastMiningImageToOverlay(image: string): void {
broadcastToOverlayWindows(IPC_CHANNELS.event.miningImage, { image });
}
const buildBroadcastRuntimeOptionsChangedMainDepsHandler =
createBuildBroadcastRuntimeOptionsChangedMainDepsHandler({
broadcastRuntimeOptionsChangedRuntime,
@@ -5811,6 +5815,7 @@ function initializeOverlayRuntime(): void {
refreshCurrentSubtitleAfterKnownWordUpdate,
);
appState.ankiIntegration?.setSubtitleMiningContextConsumer(consumePendingSubtitleMiningContext);
appState.ankiIntegration?.setMiningImageOverlayCallback(broadcastMiningImageToOverlay);
syncOverlayMpvSubtitleSuppression();
}
@@ -6896,6 +6901,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
appState.ankiIntegration?.setSubtitleMiningContextConsumer(
consumePendingSubtitleMiningContext,
);
appState.ankiIntegration?.setMiningImageOverlayCallback(broadcastMiningImageToOverlay);
},
getKnownWordCacheStatePath: () => path.join(USER_DATA_PATH, 'known-words-cache.json'),
showDesktopNotification,
+8
View File
@@ -51,6 +51,7 @@ import type {
OverlayContentMeasurement,
ShortcutsConfig,
ConfigHotReloadPayload,
MiningImagePayload,
ControllerConfigUpdate,
ControllerPreferenceUpdate,
ResolvedControllerConfig,
@@ -222,6 +223,10 @@ const onSecondarySubtitleModeEvent = createLatestValueIpcListenerWithPayload<Sec
IPC_CHANNELS.event.secondarySubtitleMode,
(payload) => payload as SecondarySubMode,
);
const onMiningImageEvent = createQueuedIpcListenerWithPayload<MiningImagePayload>(
IPC_CHANNELS.event.miningImage,
(payload) => payload as MiningImagePayload,
);
const electronAPI: ElectronAPI = {
getOverlayLayer: () => overlayLayer,
@@ -468,6 +473,9 @@ const electronAPI: ElectronAPI = {
},
);
},
onMiningImage: (callback: (payload: MiningImagePayload) => void) => {
onMiningImageEvent(callback);
},
};
contextBridge.exposeInMainWorld('electronAPI', electronAPI);
+3
View File
@@ -42,6 +42,9 @@
role="status"
aria-live="polite"
></div>
<div id="miningImageToast" class="mining-image-toast hidden" role="status" aria-live="polite">
<img id="miningImageToastImage" class="mining-image-toast-image" alt="Mined card preview" />
</div>
<div id="secondarySubContainer" class="secondary-sub-hidden">
<div id="secondarySubRoot"></div>
</div>
+24
View File
@@ -24,6 +24,7 @@ import type {
SubtitlePosition,
SubsyncManualPayload,
ConfigHotReloadPayload,
MiningImagePayload,
} from '../types';
import { createKeyboardHandlers } from './handlers/keyboard.js';
import { createGamepadController } from './handlers/gamepad-controller.js';
@@ -211,6 +212,7 @@ const keyboardHandlers = createKeyboardHandlers(ctx, {
let lastSubtitlePreview = '';
let lastSecondarySubtitlePreview = '';
let overlayErrorToastTimeout: ReturnType<typeof setTimeout> | null = null;
let miningImageToastTimeout: ReturnType<typeof setTimeout> | null = null;
let controllerAnimationFrameId: number | null = null;
function truncateForErrorLog(text: string): string {
@@ -439,6 +441,23 @@ function showOverlayErrorToast(message: string): void {
}, 3200);
}
function showMiningImageToast(image: string): void {
if (!image) {
return;
}
if (miningImageToastTimeout) {
clearTimeout(miningImageToastTimeout);
miningImageToastTimeout = null;
}
ctx.dom.miningImageToastImage.src = image;
ctx.dom.miningImageToast.classList.remove('hidden');
miningImageToastTimeout = setTimeout(() => {
ctx.dom.miningImageToast.classList.add('hidden');
ctx.dom.miningImageToastImage.removeAttribute('src');
miningImageToastTimeout = null;
}, 3000);
}
const recovery = createRendererRecoveryController({
dismissActiveUi: dismissActiveUiAfterError,
restoreOverlayInteraction: restoreOverlayInteractionAfterError,
@@ -733,6 +752,11 @@ async function init(): Promise<void> {
measurementReporter.schedule();
});
});
window.electronAPI.onMiningImage((payload: MiningImagePayload) => {
runGuarded('mining:image', () => {
showMiningImageToast(payload.image);
});
});
mouseHandlers.setupDragging();
try {
ctx.state.controllerConfig = await window.electronAPI.getControllerConfig();
+31
View File
@@ -146,6 +146,37 @@ body:focus-visible,
transform: translateY(0);
}
.mining-image-toast {
position: absolute;
top: 16px;
right: 16px;
padding: 6px;
border-radius: 10px;
border: 1px solid rgba(138, 213, 202, 0.45);
background: linear-gradient(135deg, rgba(10, 44, 40, 0.94), rgba(8, 28, 33, 0.94));
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
pointer-events: none;
opacity: 0;
transform: translateY(-6px);
transition:
opacity 160ms ease,
transform 160ms ease;
z-index: 1300;
}
.mining-image-toast-image {
display: block;
width: 128px;
height: 128px;
border-radius: 6px;
object-fit: cover;
}
.mining-image-toast:not(.hidden) {
opacity: 1;
transform: translateY(0);
}
.modal {
position: absolute;
inset: 0;
+4
View File
@@ -4,6 +4,8 @@ export type RendererDom = {
overlay: HTMLElement;
controllerStatusToast: HTMLDivElement;
overlayErrorToast: HTMLDivElement;
miningImageToast: HTMLDivElement;
miningImageToastImage: HTMLImageElement;
secondarySubContainer: HTMLElement;
secondarySubRoot: HTMLElement;
@@ -134,6 +136,8 @@ export function resolveRendererDom(): RendererDom {
overlay: getRequiredElement<HTMLElement>('overlay'),
controllerStatusToast: getRequiredElement<HTMLDivElement>('controllerStatusToast'),
overlayErrorToast: getRequiredElement<HTMLDivElement>('overlayErrorToast'),
miningImageToast: getRequiredElement<HTMLDivElement>('miningImageToast'),
miningImageToastImage: getRequiredElement<HTMLImageElement>('miningImageToastImage'),
secondarySubContainer: getRequiredElement<HTMLElement>('secondarySubContainer'),
secondarySubRoot: getRequiredElement<HTMLElement>('secondarySubRoot'),
+1
View File
@@ -144,6 +144,7 @@ export const IPC_CHANNELS = {
subtitleSidebarToggle: 'subtitle-sidebar:toggle',
primarySubtitleBarToggle: 'primary-subtitle-bar:toggle',
configHotReload: 'config:hot-reload',
miningImage: 'mining:image',
},
} as const;
+6
View File
@@ -343,6 +343,11 @@ export interface ClipboardAppendResult {
message: string;
}
export interface MiningImagePayload {
/** Screenshot of the mined frame as a data URL (e.g. `data:image/png;base64,...`). */
image: string;
}
export interface ConfigHotReloadPayload {
keybindings: Keybinding[];
sessionBindings: CompiledSessionBinding[];
@@ -541,6 +546,7 @@ export interface ElectronAPI {
) => void;
reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => void;
onConfigHotReload: (callback: (payload: ConfigHotReloadPayload) => void) => void;
onMiningImage: (callback: (payload: MiningImagePayload) => void) => void;
}
declare global {
@@ -29,7 +29,7 @@ export function AnilistSelector({
const [loading, setLoading] = useState(false);
const [linking, setLinking] = useState<number | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
inputRef.current?.focus();
@@ -53,7 +53,7 @@ export function AnilistSelector({
const handleInput = (value: string) => {
setQuery(value);
clearTimeout(debounceRef.current);
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => doSearch(value), 400);
};
+2 -23
View File
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { RetryingCoverImage } from '../common/RetryingCoverImage';
import { getStatsClient } from '../../hooks/useStatsApi';
interface AnimeCoverImageProps {
@@ -8,28 +8,7 @@ interface AnimeCoverImageProps {
}
export function AnimeCoverImage({ animeId, title, className = '' }: AnimeCoverImageProps) {
const [failed, setFailed] = useState(false);
const fallbackChar = title.charAt(0) || '?';
if (failed) {
return (
<div
className={`bg-ctp-surface2 flex items-center justify-center text-ctp-overlay2 text-2xl font-bold ${className}`}
>
{fallbackChar}
</div>
);
}
const src = getStatsClient().getAnimeCoverUrl(animeId);
return (
<img
src={src}
alt={title}
loading="lazy"
className={`object-cover bg-ctp-surface2 ${className}`}
onError={() => setFailed(true)}
/>
);
return <RetryingCoverImage src={src} alt={title} fallbackLabel={title} className={className} />;
}
@@ -0,0 +1,32 @@
interface DeleteProgressToastProps {
/** Number of sessions currently being deleted. The toast is hidden when 0. */
count: number;
}
/**
* Fixed-position toast shown while session deletions are in flight.
*
* The per-row delete buttons are only visible on hover, so once the confirm
* dialog closes the user has no signal that a (potentially slow) batch delete
* is still running. This stays on screen, independent of hover, until the work
* finishes.
*/
export function DeleteProgressToast({ count }: DeleteProgressToastProps) {
if (count <= 0) return null;
return (
<div
role="status"
aria-live="polite"
className="fixed bottom-4 right-4 z-50 flex items-center gap-3 rounded-lg border border-ctp-surface1 bg-ctp-surface0 px-4 py-3 shadow-lg shadow-black/30"
>
<span
aria-hidden="true"
className="h-4 w-4 shrink-0 animate-spin rounded-full border-2 border-ctp-surface2 border-t-ctp-red"
/>
<span className="text-sm text-ctp-text">
Deleting {count} session{count === 1 ? '' : 's'}&hellip;
</span>
</div>
);
}
@@ -0,0 +1,59 @@
import { useEffect, useState } from 'react';
import { appendCoverRetryToken, getCoverRetryDelayMs } from '../../lib/cover-retry';
interface RetryingCoverImageProps {
src: string;
alt: string;
fallbackLabel: string;
className?: string;
fallbackTextClassName?: string;
loading?: 'eager' | 'lazy';
}
export function RetryingCoverImage({
src,
alt,
fallbackLabel,
className = '',
fallbackTextClassName = 'text-2xl',
loading = 'lazy',
}: RetryingCoverImageProps) {
const [failed, setFailed] = useState(false);
const [retryToken, setRetryToken] = useState(0);
const fallbackChar = fallbackLabel.charAt(0) || '?';
useEffect(() => {
setFailed(false);
setRetryToken(0);
}, [src]);
useEffect(() => {
if (!failed) return;
const timer = setTimeout(() => {
setRetryToken((value) => value + 1);
setFailed(false);
}, getCoverRetryDelayMs(retryToken));
return () => clearTimeout(timer);
}, [failed, retryToken]);
if (failed) {
return (
<div
className={`bg-ctp-surface2 flex items-center justify-center text-ctp-overlay2 ${fallbackTextClassName} font-bold ${className}`}
>
{fallbackChar}
</div>
);
}
return (
<img
src={appendCoverRetryToken(src, retryToken)}
alt={alt}
loading={loading}
className={`object-cover bg-ctp-surface2 ${className}`}
onError={() => setFailed(true)}
onLoad={() => setFailed(false)}
/>
);
}
+2 -24
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { RetryingCoverImage } from '../common/RetryingCoverImage';
import { resolveMediaCoverApiUrl } from '../../lib/media-library-grouping';
interface CoverImageProps {
@@ -9,31 +9,9 @@ interface CoverImageProps {
}
export function CoverImage({ videoId, title, src = null, className = '' }: CoverImageProps) {
const [failed, setFailed] = useState(false);
const fallbackChar = title.charAt(0) || '?';
const resolvedSrc = src?.trim() || resolveMediaCoverApiUrl(videoId);
useEffect(() => {
setFailed(false);
}, [resolvedSrc]);
if (failed) {
return (
<div
className={`bg-ctp-surface2 flex items-center justify-center text-ctp-overlay2 text-2xl font-bold ${className}`}
>
{fallbackChar}
</div>
);
}
return (
<img
src={resolvedSrc}
alt={title}
loading="lazy"
className={`object-cover bg-ctp-surface2 ${className}`}
onError={() => setFailed(true)}
/>
<RetryingCoverImage src={resolvedSrc} alt={title} fallbackLabel={title} className={className} />
);
}
@@ -0,0 +1,41 @@
import { useEffect, useState } from 'react';
import { getCoverImageSrc, type CoverImageMap } from '../../lib/cover-images';
interface CoverThumbnailProps {
animeId: number | null;
videoId: number | null;
title: string;
coverImages: CoverImageMap;
}
export function CoverThumbnail({ animeId, videoId, title, coverImages }: CoverThumbnailProps) {
const [failed, setFailed] = useState(false);
const fallbackChar = title.charAt(0) || '?';
const fallback = (
<div className="w-12 h-16 rounded bg-ctp-surface2 flex items-center justify-center text-ctp-overlay2 text-lg font-bold shrink-0">
{fallbackChar}
</div>
);
const src =
animeId != null
? getCoverImageSrc(coverImages, 'anime', animeId)
: getCoverImageSrc(coverImages, 'media', videoId);
useEffect(() => {
setFailed(false);
}, [src]);
if (!src || failed) {
return fallback;
}
return (
<img
src={src}
alt=""
className="w-12 h-16 rounded object-cover shrink-0 bg-ctp-surface2"
onError={() => setFailed(true)}
/>
);
}
@@ -6,6 +6,7 @@ import { StreakCalendar } from './StreakCalendar';
import { RecentSessions } from './RecentSessions';
import { TrackingSnapshot } from './TrackingSnapshot';
import { TrendChart } from '../trends/TrendChart';
import { DeleteProgressToast } from '../common/DeleteProgressToast';
import { buildOverviewSummary, buildStreakCalendar } from '../../lib/dashboard-data';
import { apiClient } from '../../lib/api-client';
import { getStatsClient } from '../../hooks/useStatsApi';
@@ -153,6 +154,8 @@ export function OverviewTab({ onNavigateToMediaDetail, onNavigateToSession }: Ov
onDeleteAnimeGroup={handleDeleteAnimeGroup}
deletingIds={deletingIds}
/>
<DeleteProgressToast count={deletingIds.size} />
</div>
);
}
@@ -5,7 +5,9 @@ import {
formatNumber,
formatSessionDayLabel,
} from '../../lib/formatters';
import { BASE_URL } from '../../lib/api-client';
import { CoverThumbnail } from './CoverThumbnail';
import { useCoverImages } from '../../hooks/useCoverImages';
import type { CoverImageMap } from '../../lib/cover-images';
import { getSessionDisplayWordCount } from '../../lib/session-word-count';
import { getSessionNavigationTarget } from '../../lib/stats-navigation';
import type { SessionSummary } from '../../types/stats';
@@ -85,53 +87,20 @@ function groupSessionsByAnime(sessions: SessionSummary[]): AnimeGroup[] {
return Array.from(map.values());
}
function CoverThumbnail({
animeId,
videoId,
title,
}: {
animeId: number | null;
videoId: number | null;
title: string;
}) {
const fallbackChar = title.charAt(0) || '?';
const [isFallback, setIsFallback] = useState(false);
if ((!animeId && !videoId) || isFallback) {
return (
<div className="w-12 h-16 rounded bg-ctp-surface2 flex items-center justify-center text-ctp-overlay2 text-lg font-bold shrink-0">
{fallbackChar}
</div>
);
}
const src =
animeId != null
? `${BASE_URL}/api/stats/anime/${animeId}/cover`
: `${BASE_URL}/api/stats/media/${videoId}/cover`;
return (
<img
src={src}
alt=""
className="w-12 h-16 rounded object-cover shrink-0 bg-ctp-surface2"
onError={() => setIsFallback(true)}
/>
);
}
function SessionItem({
session,
onNavigateToMediaDetail,
onNavigateToSession,
onDelete,
deleteDisabled,
coverImages,
}: {
session: SessionSummary;
onNavigateToMediaDetail: (videoId: number, sessionId?: number | null) => void;
onNavigateToSession: (sessionId: number) => void;
onDelete: () => void;
deleteDisabled: boolean;
coverImages: CoverImageMap;
}) {
const displayWordCount = getSessionDisplayWordCount(session);
const navigationTarget = getSessionNavigationTarget(session);
@@ -153,6 +122,7 @@ function SessionItem({
animeId={session.animeId}
videoId={session.videoId}
title={session.canonicalTitle ?? 'Unknown'}
coverImages={coverImages}
/>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-ctp-text truncate">
@@ -205,6 +175,7 @@ function AnimeGroupRow({
onDeleteSession,
onDeleteAnimeGroup,
deletingIds,
coverImages,
}: {
group: AnimeGroup;
onNavigateToMediaDetail: (videoId: number, sessionId?: number | null) => void;
@@ -212,6 +183,7 @@ function AnimeGroupRow({
onDeleteSession: (session: SessionSummary) => void;
onDeleteAnimeGroup: (group: AnimeGroup) => void;
deletingIds: Set<number>;
coverImages: CoverImageMap;
}) {
const [expanded, setExpanded] = useState(false);
const groupDeleting = group.sessions.some((s) => deletingIds.has(s.sessionId));
@@ -225,6 +197,7 @@ function AnimeGroupRow({
onNavigateToSession={onNavigateToSession}
onDelete={() => onDeleteSession(s)}
deleteDisabled={deletingIds.has(s.sessionId)}
coverImages={coverImages}
/>
);
}
@@ -247,6 +220,7 @@ function AnimeGroupRow({
animeId={group.animeId}
videoId={mostRecentSession.videoId}
title={displayTitle}
coverImages={coverImages}
/>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-ctp-text truncate">{displayTitle}</div>
@@ -319,6 +293,7 @@ function AnimeGroupRow({
animeId={s.animeId}
videoId={s.videoId}
title={s.canonicalTitle ?? 'Unknown'}
coverImages={coverImages}
/>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-ctp-subtext1 truncate">
@@ -378,6 +353,8 @@ export function RecentSessions({
onDeleteAnimeGroup,
deletingIds,
}: RecentSessionsProps) {
const coverImages = useCoverImages(sessions);
if (sessions.length === 0) {
return (
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
@@ -422,6 +399,7 @@ export function RecentSessions({
onDeleteSession={onDeleteSession}
onDeleteAnimeGroup={(g) => onDeleteAnimeGroup(g.sessions)}
deletingIds={deletingIds}
coverImages={coverImages}
/>
))}
</div>
@@ -71,6 +71,63 @@ test('buildBucketDeleteHandler deletes every session in the bucket when confirm
assert.equal(onErrorCalled, false);
});
test('buildBucketDeleteHandler signals onStart after confirm, before deleting', async () => {
const events: string[] = [];
const bucket = makeBucket([
makeSession({ sessionId: 11 }),
makeSession({ sessionId: 22 }),
makeSession({ sessionId: 33 }),
]);
const handler = buildBucketDeleteHandler({
bucket,
apiClient: {
deleteSessions: async () => {
events.push('delete');
},
},
confirm: () => {
events.push('confirm');
return true;
},
onStart: (count) => {
events.push(`start:${count}`);
},
onSuccess: () => {
events.push('success');
},
onError: () => {
events.push('error');
},
});
await handler();
assert.deepEqual(events, ['confirm', 'start:3', 'delete', 'success']);
});
test('buildBucketDeleteHandler does not call onStart when confirm returns false', async () => {
let startCalled = false;
const bucket = makeBucket([makeSession({ sessionId: 1 }), makeSession({ sessionId: 2 })]);
const handler = buildBucketDeleteHandler({
bucket,
apiClient: { deleteSessions: async () => {} },
confirm: () => false,
onStart: () => {
startCalled = true;
},
onSuccess: () => {},
onError: () => {},
});
await handler();
assert.equal(startCalled, false);
});
test('buildBucketDeleteHandler is a no-op when confirm returns false', async () => {
let deleteCalled = false;
let successCalled = false;
+12 -1
View File
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from 'react';
import { useSessions } from '../../hooks/useSessions';
import { SessionRow } from './SessionRow';
import { SessionDetail } from './SessionDetail';
import { DeleteProgressToast } from '../common/DeleteProgressToast';
import { apiClient } from '../../lib/api-client';
import { confirmBucketDelete, confirmSessionDelete } from '../../lib/delete-confirm';
import { formatDuration, formatNumber, formatSessionDayLabel } from '../../lib/formatters';
@@ -28,6 +29,8 @@ export interface BucketDeleteDeps {
bucket: SessionBucket;
apiClient: { deleteSessions: (ids: number[]) => Promise<void> };
confirm: (title: string, count: number) => boolean | Promise<boolean>;
/** Called once confirmation passes, just before the delete request begins. */
onStart?: (count: number) => void;
onSuccess: (deletedIds: number[]) => void;
onError: (message: string) => void;
}
@@ -39,12 +42,13 @@ export interface BucketDeleteDeps {
* rendering the full SessionsTab or mocking React state.
*/
export function buildBucketDeleteHandler(deps: BucketDeleteDeps): () => Promise<void> {
const { bucket, apiClient: client, confirm, onSuccess, onError } = deps;
const { bucket, apiClient: client, confirm, onStart, onSuccess, onError } = deps;
return async () => {
const title = bucket.representativeSession.canonicalTitle ?? 'this episode';
const ids = bucket.sessions.map((s) => s.sessionId);
try {
if (!(await confirm(title, ids.length))) return;
onStart?.(ids.length);
await client.deleteSessions(ids);
onSuccess(ids);
} catch (err) {
@@ -72,6 +76,7 @@ export function SessionsTab({
const [deleteError, setDeleteError] = useState<string | null>(null);
const [deletingSessionId, setDeletingSessionId] = useState<number | null>(null);
const [deletingBucketKey, setDeletingBucketKey] = useState<string | null>(null);
const [deletingCount, setDeletingCount] = useState(0);
useEffect(() => {
setVisibleSessions(sessions);
@@ -131,6 +136,7 @@ export function SessionsTab({
setDeleteError(null);
setDeletingSessionId(session.sessionId);
setDeletingCount(1);
try {
await apiClient.deleteSession(session.sessionId);
setVisibleSessions((prev) => prev.filter((item) => item.sessionId !== session.sessionId));
@@ -139,6 +145,7 @@ export function SessionsTab({
setDeleteError(err instanceof Error ? err.message : 'Failed to delete session.');
} finally {
setDeletingSessionId(null);
setDeletingCount(0);
}
};
@@ -149,6 +156,7 @@ export function SessionsTab({
bucket,
apiClient,
confirm: confirmBucketDelete,
onStart: (count) => setDeletingCount(count),
onSuccess: (ids) => {
const deleted = new Set(ids);
setVisibleSessions((prev) => prev.filter((s) => !deleted.has(s.sessionId)));
@@ -166,6 +174,7 @@ export function SessionsTab({
await handler();
} finally {
setDeletingBucketKey(null);
setDeletingCount(0);
}
};
@@ -305,6 +314,8 @@ export function SessionsTab({
{search.trim() ? 'No sessions matching your search.' : 'No sessions recorded yet.'}
</div>
)}
<DeleteProgressToast count={deletingCount} />
</div>
);
}
+65
View File
@@ -0,0 +1,65 @@
import { useEffect, useMemo, useState } from 'react';
import {
collectSessionCoverRequests,
getCoverImageKey,
mergeCoverImageData,
type CoverImageMap,
} from '../lib/cover-images';
import { getCoverRetryDelayMs } from '../lib/cover-retry';
import type { SessionSummary } from '../types/stats';
import { getStatsClient } from './useStatsApi';
function buildRequestKey(animeIds: number[], videoIds: number[]): string {
return `a:${animeIds.join(',')}|m:${videoIds.join(',')}`;
}
export function useCoverImages(sessions: SessionSummary[]): CoverImageMap {
const requests = useMemo(() => collectSessionCoverRequests(sessions), [sessions]);
const requestKey = useMemo(
() => buildRequestKey(requests.animeIds, requests.videoIds),
[requests],
);
const [images, setImages] = useState<CoverImageMap>({});
useEffect(() => {
let cancelled = false;
let timer: ReturnType<typeof setTimeout> | null = null;
let cachedImages: CoverImageMap = {};
const client = getStatsClient();
async function load(animeIds: number[], videoIds: number[], attempt: number): Promise<void> {
if (animeIds.length === 0 && videoIds.length === 0) {
return;
}
try {
const data = await client.getCoverImages({ animeIds, videoIds });
if (cancelled) return;
cachedImages = mergeCoverImageData(cachedImages, data);
setImages(cachedImages);
} catch {
if (cancelled) return;
}
const missingAnimeIds = animeIds.filter((id) => !cachedImages[getCoverImageKey('anime', id)]);
const missingVideoIds = videoIds.filter((id) => !cachedImages[getCoverImageKey('media', id)]);
if (missingAnimeIds.length === 0 && missingVideoIds.length === 0) {
return;
}
timer = setTimeout(() => {
void load(missingAnimeIds, missingVideoIds, attempt + 1);
}, getCoverRetryDelayMs(attempt));
}
setImages({});
void load(requests.animeIds, requests.videoIds, 0);
return () => {
cancelled = true;
if (timer) clearTimeout(timer);
};
}, [requestKey]);
return images;
}
+37
View File
@@ -32,6 +32,43 @@ test('resolveStatsBaseUrl keeps legacy localhost fallback for file mode without
assert.equal(baseUrl, 'http://127.0.0.1:6969');
});
test('getAnimeCoverUrl appends retry tokens for late cover refreshes', () => {
const getAnimeCoverUrl = apiClient.getAnimeCoverUrl as (
animeId: number,
retryToken?: number,
) => string;
assert.equal(
getAnimeCoverUrl(42, 3),
'http://127.0.0.1:6969/api/stats/anime/42/cover?coverRetry=3',
);
});
test('getCoverImages batches anime and media cover requests', async () => {
const originalFetch = globalThis.fetch;
let seenUrl = '';
let seenMethod = '';
let seenBody = '';
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
seenUrl = String(input);
seenMethod = init?.method ?? 'GET';
seenBody = String(init?.body ?? '');
return new Response(JSON.stringify({ anime: {}, media: {} }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}) as typeof globalThis.fetch;
try {
await apiClient.getCoverImages({ animeIds: [1, 1, 2], videoIds: [7, 7, 8] });
assert.equal(seenUrl, `${BASE_URL}/api/stats/covers`);
assert.equal(seenMethod, 'POST');
assert.deepEqual(JSON.parse(seenBody), { animeIds: [1, 2], videoIds: [7, 8] });
} finally {
globalThis.fetch = originalFetch;
}
});
test('deleteSession sends a DELETE request to the session endpoint', async () => {
const originalFetch = globalThis.fetch;
let seenUrl = '';
+29 -1
View File
@@ -23,7 +23,9 @@ import type {
EpisodeDetailData,
StatsAnkiNoteInfo,
StatsExcludedWord,
StatsCoverImagesData,
} from '../types/stats';
import { appendCoverRetryToken } from './cover-retry';
type StatsLocationLike = Pick<Location, 'protocol' | 'origin' | 'search'>;
@@ -65,6 +67,16 @@ async function fetchJson<T>(path: string): Promise<T> {
return res.json() as Promise<T>;
}
function uniquePositiveIds(ids: number[]): number[] {
const uniqueIds = new Set<number>();
for (const id of ids) {
if (Number.isFinite(id) && id > 0) {
uniqueIds.add(Math.floor(id));
}
}
return Array.from(uniqueIds).sort((a, b) => a - b);
}
export const apiClient = {
getOverview: () => fetchJson<OverviewData>('/api/stats/overview'),
getDailyRollups: (limit = 60) =>
@@ -116,7 +128,22 @@ export const apiClient = {
fetchJson<AnimeWord[]>(`/api/stats/anime/${animeId}/words?limit=${limit}`),
getAnimeRollups: (animeId: number, limit = 90) =>
fetchJson<DailyRollup[]>(`/api/stats/anime/${animeId}/rollups?limit=${limit}`),
getAnimeCoverUrl: (animeId: number) => `${BASE_URL}/api/stats/anime/${animeId}/cover`,
getAnimeCoverUrl: (animeId: number, retryToken = 0) =>
appendCoverRetryToken(`${BASE_URL}/api/stats/anime/${animeId}/cover`, retryToken),
getCoverImages: async (params: {
animeIds: number[];
videoIds: number[];
}): Promise<StatsCoverImagesData> => {
const res = await fetchResponse('/api/stats/covers', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
animeIds: uniquePositiveIds(params.animeIds),
videoIds: uniquePositiveIds(params.videoIds),
}),
});
return res.json() as Promise<StatsCoverImagesData>;
},
getStreakCalendar: (days = 90) =>
fetchJson<StreakCalendarDay[]>(`/api/stats/streak-calendar?days=${days}`),
getEpisodesPerDay: (limit = 90) =>
@@ -175,6 +202,7 @@ export const apiClient = {
episodes: number | null;
season: string | null;
seasonYear: number | null;
description: string | null;
coverImage: { large: string | null; medium: string | null } | null;
title: { romaji: string | null; english: string | null; native: string | null } | null;
}>
+44
View File
@@ -0,0 +1,44 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { collectSessionCoverRequests, getCoverImageKey } from './cover-images';
import type { SessionSummary } from '../types/stats';
function makeSession(overrides: Partial<SessionSummary> & { sessionId: number }): SessionSummary {
const { sessionId, ...rest } = overrides;
return {
sessionId,
canonicalTitle: null,
videoId: null,
animeId: null,
animeTitle: null,
startedAtMs: 0,
endedAtMs: null,
totalWatchedMs: 0,
activeWatchedMs: 0,
linesSeen: 0,
tokensSeen: 0,
cardsMined: 0,
lookupCount: 0,
lookupHits: 0,
yomitanLookupCount: 0,
knownWordsSeen: 0,
knownWordRate: 0,
...rest,
};
}
test('collectSessionCoverRequests dedupes anime ids and only requests media for ungrouped sessions', () => {
const requests = collectSessionCoverRequests([
makeSession({ sessionId: 1, animeId: 10, videoId: 100 }),
makeSession({ sessionId: 2, animeId: 10, videoId: 101 }),
makeSession({ sessionId: 3, animeId: null, videoId: 200 }),
makeSession({ sessionId: 4, animeId: null, videoId: 200 }),
]);
assert.deepEqual(requests, { animeIds: [10], videoIds: [200] });
});
test('getCoverImageKey separates anime and media ids', () => {
assert.equal(getCoverImageKey('anime', 1), 'anime:1');
assert.equal(getCoverImageKey('media', 1), 'media:1');
});
+72
View File
@@ -0,0 +1,72 @@
import type { SessionSummary, StatsCoverImagesData } from '../types/stats';
export type CoverImageKind = 'anime' | 'media';
export type CoverImageMap = Record<string, string>;
export interface CoverImageRequest {
animeIds: number[];
videoIds: number[];
}
function normalizePositiveIds(ids: Iterable<number | null | undefined>): number[] {
const uniqueIds = new Set<number>();
for (const id of ids) {
if (typeof id === 'number' && Number.isFinite(id) && id > 0) {
uniqueIds.add(Math.floor(id));
}
}
return Array.from(uniqueIds).sort((a, b) => a - b);
}
export function getCoverImageKey(kind: CoverImageKind, id: number): string {
return `${kind}:${id}`;
}
export function collectSessionCoverRequests(
sessions: Pick<SessionSummary, 'animeId' | 'videoId'>[],
): CoverImageRequest {
const animeIds: number[] = [];
const videoIds: number[] = [];
for (const session of sessions) {
if (session.animeId != null) {
animeIds.push(session.animeId);
} else if (session.videoId != null) {
videoIds.push(session.videoId);
}
}
return {
animeIds: normalizePositiveIds(animeIds),
videoIds: normalizePositiveIds(videoIds),
};
}
export function mergeCoverImageData(
previous: CoverImageMap,
data: StatsCoverImagesData,
): CoverImageMap {
const next = { ...previous };
for (const [id, image] of Object.entries(data.anime)) {
if (image?.dataUrl) {
next[getCoverImageKey('anime', Number(id))] = image.dataUrl;
}
}
for (const [id, image] of Object.entries(data.media)) {
if (image?.dataUrl) {
next[getCoverImageKey('media', Number(id))] = image.dataUrl;
}
}
return next;
}
export function getCoverImageSrc(
images: CoverImageMap,
kind: CoverImageKind,
id: number | null,
): string | null {
return id == null ? null : (images[getCoverImageKey(kind, id)] ?? null);
}
+22
View File
@@ -0,0 +1,22 @@
const COVER_RETRY_PARAM = 'coverRetry';
export function appendCoverRetryToken(src: string, retryToken = 0): string {
if (!Number.isFinite(retryToken) || retryToken <= 0) return src;
const normalizedToken = String(Math.trunc(retryToken));
try {
const url = new URL(src, 'http://subminer.local');
url.searchParams.set(COVER_RETRY_PARAM, normalizedToken);
if (src.startsWith('/')) {
return `${url.pathname}${url.search}${url.hash}`;
}
return url.toString();
} catch {
const separator = src.includes('?') ? '&' : '?';
return `${src}${separator}${COVER_RETRY_PARAM}=${encodeURIComponent(normalizedToken)}`;
}
}
export function getCoverRetryDelayMs(retryToken: number): number {
return Math.min(30_000, 2_000 * 2 ** Math.min(Math.max(retryToken, 0), 4));
}
@@ -5,6 +5,7 @@ import type { MediaLibraryItem } from '../types/stats';
import {
groupMediaLibraryItems,
resolveMediaArtworkUrl,
resolveMediaCoverApiUrl,
summarizeMediaLibraryGroups,
} from './media-library-grouping';
import { CoverImage } from '../components/library/CoverImage';
@@ -172,6 +173,13 @@ test('MediaCard uses the proxied cover endpoint instead of metadata artwork urls
assert.doesNotMatch(markup, /https:\/\/i\.ytimg\.com\/vi\/yt-1\/hqdefault\.jpg/);
});
test('resolveMediaCoverApiUrl appends retry tokens for late cover refreshes', () => {
assert.equal(
resolveMediaCoverApiUrl(youtubeEpisodeA.videoId, 2),
'http://127.0.0.1:6969/api/stats/media/1/cover?coverRetry=2',
);
});
test('MediaCard prefers youtube video title over canonical fallback url slug', () => {
const markup = renderToStaticMarkup(<MediaCard item={youtubeEpisodeA} onClick={() => {}} />);
+3 -2
View File
@@ -1,4 +1,5 @@
import { BASE_URL } from './api-client';
import { appendCoverRetryToken } from './cover-retry';
import type { MediaLibraryItem } from '../types/stats';
export interface MediaLibraryGroup {
@@ -22,8 +23,8 @@ export function resolveMediaArtworkUrl(
return normalized.length > 0 ? normalized : null;
}
export function resolveMediaCoverApiUrl(videoId: number): string {
return `${BASE_URL}/api/stats/media/${videoId}/cover`;
export function resolveMediaCoverApiUrl(videoId: number, retryToken = 0): string {
return appendCoverRetryToken(`${BASE_URL}/api/stats/media/${videoId}/cover`, retryToken);
}
export function summarizeMediaLibraryGroups(groups: MediaLibraryGroup[]): {
+12 -11
View File
@@ -1,51 +1,52 @@
import { describe, it, expect } from 'vitest';
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { fullReading } from './reading-utils';
describe('fullReading', () => {
it('prefixes leading hiragana from headword', () => {
// お前 with reading まえ → おまえ
expect(fullReading('お前', 'まえ')).toBe('おまえ');
assert.equal(fullReading('お前', 'まえ'), 'おまえ');
});
it('handles katakana stored readings', () => {
// お前 with katakana reading マエ → おまえ
expect(fullReading('お前', 'マエ')).toBe('おまえ');
assert.equal(fullReading('お前', 'マエ'), 'おまえ');
});
it('returns stored reading when it already includes leading kana', () => {
// Reading already correct
expect(fullReading('お前', 'おまえ')).toBe('おまえ');
assert.equal(fullReading('お前', 'おまえ'), 'おまえ');
});
it('handles trailing hiragana', () => {
// 隠す with reading かくす — す is trailing hiragana
expect(fullReading('隠す', 'かくす')).toBe('かくす');
assert.equal(fullReading('隠す', 'かくす'), 'かくす');
});
it('handles pure kanji headwords', () => {
expect(fullReading('様', 'さま')).toBe('さま');
assert.equal(fullReading('様', 'さま'), 'さま');
});
it('returns empty for empty reading', () => {
expect(fullReading('前', '')).toBe('');
assert.equal(fullReading('前', ''), '');
});
it('returns empty for empty headword', () => {
expect(fullReading('', 'まえ')).toBe('まえ');
assert.equal(fullReading('', 'まえ'), 'まえ');
});
it('handles all-kana headword', () => {
// Headword is already all hiragana
expect(fullReading('いますぐ', 'いますぐ')).toBe('いますぐ');
assert.equal(fullReading('いますぐ', 'いますぐ'), 'いますぐ');
});
it('handles mixed leading and trailing kana', () => {
// お気に入り: お=leading, に入り=trailing around 気
expect(fullReading('お気に入り', 'きにいり')).toBe('おきにいり');
assert.equal(fullReading('お気に入り', 'きにいり'), 'おきにいり');
});
it('handles katakana in headword', () => {
// カズマ様 — leading katakana + kanji
expect(fullReading('カズマ様', 'さま')).toBe('かずまさま');
assert.equal(fullReading('カズマ様', 'さま'), 'かずまさま');
});
});
+8 -4
View File
@@ -41,8 +41,10 @@ export function fullReading(headword: string, storedReading: string): string {
const chars = [...headword];
let i = 0;
while (i < chars.length && (isHiragana(chars[i]) || isKatakana(chars[i]))) {
leadingKana.push(katakanaToHiragana(chars[i]));
while (i < chars.length) {
const ch = chars[i]!;
if (!isHiragana(ch) && !isKatakana(ch)) break;
leadingKana.push(katakanaToHiragana(ch));
i++;
}
@@ -51,8 +53,10 @@ export function fullReading(headword: string, storedReading: string): string {
}
let j = chars.length - 1;
while (j > i && (isHiragana(chars[j]) || isKatakana(chars[j]))) {
trailingKana.unshift(katakanaToHiragana(chars[j]));
while (j > i) {
const ch = chars[j]!;
if (!isHiragana(ch) && !isKatakana(ch)) break;
trailingKana.unshift(katakanaToHiragana(ch));
j--;
}
+3 -2
View File
@@ -4,8 +4,9 @@ import type { SessionSummary } from '../types/stats';
import { groupSessionsByVideo } from './session-grouping';
function makeSession(overrides: Partial<SessionSummary> & { sessionId: number }): SessionSummary {
const { sessionId, ...rest } = overrides;
return {
sessionId: overrides.sessionId,
sessionId,
canonicalTitle: null,
videoId: null,
animeId: null,
@@ -22,7 +23,7 @@ function makeSession(overrides: Partial<SessionSummary> & { sessionId: number })
yomitanLookupCount: 0,
knownWordsSeen: 0,
knownWordRate: 0,
...overrides,
...rest,
};
}
-1
View File
@@ -132,7 +132,6 @@ test('AnimeOverviewStats renders aggregate Yomitan lookup metrics', () => {
episodeCount: 3,
lastWatchedMs: 0,
}}
avgSessionMs={20_000}
knownWordsSummary={null}
/>,
);
+1
View File
@@ -1,5 +1,6 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
const css = readFileSync(fileURLToPath(new URL('./globals.css', import.meta.url)), 'utf8');
+10
View File
@@ -82,6 +82,16 @@ export interface StatsExcludedWord {
reading: string;
}
export interface StatsCoverImage {
contentType: string;
dataUrl: string;
}
export interface StatsCoverImagesData {
anime: Record<string, StatsCoverImage | null>;
media: Record<string, StatsCoverImage | null>;
}
export interface KanjiEntry {
kanjiId: number;
kanji: string;