diff --git a/backlog/tasks/task-346 - Fix-stats-session-detail-when-recent-media-is-missing.md b/backlog/tasks/task-346 - Fix-stats-session-detail-when-recent-media-is-missing.md new file mode 100644 index 00000000..ce403790 --- /dev/null +++ b/backlog/tasks/task-346 - Fix-stats-session-detail-when-recent-media-is-missing.md @@ -0,0 +1,57 @@ +--- +id: TASK-346 +title: Fix stats session detail when recent media is missing +status: Done +assignee: + - Codex +created_date: '2026-05-12 06:41' +updated_date: '2026-05-12 06:44' +labels: + - bug + - stats +dependencies: [] +priority: high +--- + +## Description + + +Stats overview can show a completed session, but clicking it opens a detail view that says "Media not found". The details view should resolve the session/media consistently for recently completed local playback so users can inspect session cards, words, timeline, and media stats after a video finishes. + + +## Acceptance Criteria + +- [x] #1 A completed session listed on the stats overview opens a usable details view instead of "Media not found" when its media is still present in stats data. +- [x] #2 Regression coverage reproduces the overview-to-detail lookup mismatch and verifies the corrected behavior. +- [x] #3 Relevant stats/detail documentation is updated if behavior or APIs change. + + +## Implementation Plan + + +1. Add a focused regression test in `src/core/services/immersion-tracker/__tests__/query.test.ts` covering a video/session visible from session summaries before `imm_lifetime_media` exists. +2. Update `getMediaDetail` in `src/core/services/immersion-tracker/query-library.ts` so detail rows can resolve from `imm_videos` plus session metrics when lifetime summary is absent. +3. Run the focused query test lane and update task notes/acceptance criteria. + + +## Implementation Notes + + +Implemented root-cause fix in `getMediaDetail`: media detail now resolves from `imm_videos` plus session metrics when `imm_lifetime_media` is not populated yet, while still preferring lifetime summary totals when available. Added regression test for session-visible/media-detail-missing mismatch. No docs update required because the API shape is unchanged; added changelog fragment `changes/346-stats-session-detail.md`. Verification: `bun test src/core/services/immersion-tracker/__tests__/query.test.ts`, `bun run typecheck`, `bun run format:check:src`, `bun run test:fast`, `bun run changelog:lint`. + + +## Final Summary + + +Summary: +- Fixed stats media detail lookup so sessions visible in overview can open detail even before lifetime media summaries exist. +- Preserved lifetime-summary totals when available and added a regression test for the missing-lifetime case. +- Added `changes/346-stats-session-detail.md` for the user-visible fix. + +Tests: +- `bun test src/core/services/immersion-tracker/__tests__/query.test.ts` +- `bun run typecheck` +- `bun run format:check:src` +- `bun run test:fast` +- `bun run changelog:lint` + diff --git a/changes/346-stats-session-detail.md b/changes/346-stats-session-detail.md new file mode 100644 index 00000000..4fc61465 --- /dev/null +++ b/changes/346-stats-session-detail.md @@ -0,0 +1,4 @@ +type: fixed +area: stats + +- Fixed recent session detail pages showing "Media not found" before lifetime media summaries are available. diff --git a/src/core/services/immersion-tracker/__tests__/query.test.ts b/src/core/services/immersion-tracker/__tests__/query.test.ts index bf21759e..72cf0f27 100644 --- a/src/core/services/immersion-tracker/__tests__/query.test.ts +++ b/src/core/services/immersion-tracker/__tests__/query.test.ts @@ -3050,6 +3050,53 @@ test('anime and media detail prefer lifetime totals over partial retained sessio } }); +test('media detail resolves retained sessions before lifetime summary exists', () => { + const dbPath = makeDbPath(); + const db = new Database(dbPath); + + try { + ensureSchema(db); + + const videoId = getOrCreateVideoRecord(db, 'local:/tmp/recent-session.mkv', { + canonicalTitle: 'Recent Session Episode', + sourcePath: '/tmp/recent-session.mkv', + sourceUrl: null, + sourceType: SOURCE_TYPE_LOCAL, + }); + const startedAtMs = 1_700_000_000_000; + const { sessionId } = startSessionRecord(db, videoId, startedAtMs); + db.prepare( + ` + UPDATE imm_sessions + SET ended_at_ms = ?, status = 2, active_watched_ms = ?, lines_seen = ?, tokens_seen = ?, cards_mined = ? + WHERE session_id = ? + `, + ).run(startedAtMs + 600_000, 600_000, 100, 990, 1, sessionId); + + assert.equal(getSessionSummaries(db, 1)[0]?.videoId, videoId); + assert.equal( + ( + db + .prepare('SELECT COUNT(*) AS total FROM imm_lifetime_media WHERE video_id = ?') + .get(videoId) as { total: number } + ).total, + 0, + ); + + const detail = getMediaDetail(db, videoId); + assert.ok(detail); + assert.equal(detail.canonicalTitle, 'Recent Session Episode'); + assert.equal(detail.totalSessions, 1); + assert.equal(detail.totalActiveMs, 600_000); + assert.equal(detail.totalLinesSeen, 100); + assert.equal(detail.totalTokensSeen, 990); + assert.equal(detail.totalCards, 1); + } finally { + db.close(); + cleanupDbPath(dbPath); + } +}); + test('media library and detail queries read lifetime totals', () => { const dbPath = makeDbPath(); const db = new Database(dbPath); diff --git a/src/core/services/immersion-tracker/query-library.ts b/src/core/services/immersion-tracker/query-library.ts index 1202c5a5..89a4f13e 100644 --- a/src/core/services/immersion-tracker/query-library.ts +++ b/src/core/services/immersion-tracker/query-library.ts @@ -251,11 +251,26 @@ export function getMediaDetail(db: DatabaseSync, videoId: number): MediaDetailRo v.video_id AS videoId, v.canonical_title AS canonicalTitle, v.anime_id AS animeId, - COALESCE(lm.total_sessions, 0) AS totalSessions, - COALESCE(lm.total_active_ms, 0) AS totalActiveMs, - COALESCE(lm.total_cards, 0) AS totalCards, - COALESCE(lm.total_tokens_seen, 0) AS totalTokensSeen, - COALESCE(lm.total_lines_seen, 0) AS totalLinesSeen, + CASE + WHEN lm.video_id IS NOT NULL THEN COALESCE(lm.total_sessions, 0) + ELSE COUNT(DISTINCT s.session_id) + END AS totalSessions, + CASE + WHEN lm.video_id IS NOT NULL THEN COALESCE(lm.total_active_ms, 0) + ELSE COALESCE(SUM(COALESCE(asm.activeWatchedMs, s.active_watched_ms, 0)), 0) + END AS totalActiveMs, + CASE + WHEN lm.video_id IS NOT NULL THEN COALESCE(lm.total_cards, 0) + ELSE COALESCE(SUM(COALESCE(asm.cardsMined, s.cards_mined, 0)), 0) + END AS totalCards, + CASE + WHEN lm.video_id IS NOT NULL THEN COALESCE(lm.total_tokens_seen, 0) + ELSE COALESCE(SUM(COALESCE(asm.tokensSeen, s.tokens_seen, 0)), 0) + END AS totalTokensSeen, + CASE + WHEN lm.video_id IS NOT NULL THEN COALESCE(lm.total_lines_seen, 0) + ELSE COALESCE(SUM(COALESCE(asm.linesSeen, s.lines_seen, 0)), 0) + END AS totalLinesSeen, COALESCE(SUM(COALESCE(asm.lookupCount, s.lookup_count, 0)), 0) AS totalLookupCount, COALESCE(SUM(COALESCE(asm.lookupHits, s.lookup_hits, 0)), 0) AS totalLookupHits, COALESCE(SUM(COALESCE(asm.yomitanLookupCount, s.yomitan_lookup_count, 0)), 0) AS totalYomitanLookupCount, @@ -271,11 +286,12 @@ export function getMediaDetail(db: DatabaseSync, videoId: number): MediaDetailRo yv.uploader_url AS uploaderUrl, yv.description AS description FROM imm_videos v - JOIN imm_lifetime_media lm ON lm.video_id = v.video_id + LEFT JOIN imm_lifetime_media lm ON lm.video_id = v.video_id LEFT JOIN imm_youtube_videos yv ON yv.video_id = v.video_id LEFT JOIN imm_sessions s ON s.video_id = v.video_id LEFT JOIN active_session_metrics asm ON asm.sessionId = s.session_id WHERE v.video_id = ? + AND (lm.video_id IS NOT NULL OR s.session_id IS NOT NULL) GROUP BY v.video_id `, )