mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-20 12:11:28 -07:00
fix(stats): use yomitan tokens for subtitle counts
This commit is contained in:
@@ -0,0 +1,56 @@
|
|||||||
|
---
|
||||||
|
id: TASK-189
|
||||||
|
title: Replace stats word counts with Yomitan token counts
|
||||||
|
status: Done
|
||||||
|
assignee:
|
||||||
|
- codex
|
||||||
|
created_date: '2026-03-18 01:35'
|
||||||
|
updated_date: '2026-03-18 05:28'
|
||||||
|
labels:
|
||||||
|
- stats
|
||||||
|
- tokenizer
|
||||||
|
- bug
|
||||||
|
milestone: m-1
|
||||||
|
dependencies: []
|
||||||
|
references:
|
||||||
|
- src/core/services/immersion-tracker-service.ts
|
||||||
|
- src/core/services/immersion-tracker/reducer.ts
|
||||||
|
- src/core/services/immersion-tracker/storage.ts
|
||||||
|
- src/core/services/immersion-tracker/query.ts
|
||||||
|
- src/core/services/immersion-tracker/lifetime.ts
|
||||||
|
- stats/src/components
|
||||||
|
- stats/src/lib/yomitan-lookup.ts
|
||||||
|
priority: medium
|
||||||
|
ordinal: 100500
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
Replace heuristic immersion stats word counting with Yomitan token counts. Session/media/anime stats should use the exact merged Yomitan token stream as the denominator and display metric, with no whitespace/CJK-character fallback and no active `wordsSeen` concept in the runtime, storage, API, or stats UI.
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
<!-- AC:BEGIN -->
|
||||||
|
- [x] #1 `recordSubtitleLine` derives session count deltas from Yomitan token arrays instead of `calculateTextMetrics`.
|
||||||
|
- [x] #2 Active immersion tracking/storage/query code no longer depends on `wordsSeen` / `totalWordsSeen` fields for stats behavior.
|
||||||
|
- [x] #3 Stats UI labels and lookup-rate copy refer to tokens instead of words where those counts are shown to users.
|
||||||
|
- [x] #4 Regression tests cover token-count sourcing, zero-count behavior when tokenization payload is absent, and updated stats copy.
|
||||||
|
- [x] #5 A changelog fragment documents the user-visible stats denominator change.
|
||||||
|
<!-- AC:END -->
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
<!-- SECTION:PLAN:BEGIN -->
|
||||||
|
1. Add failing tracker tests proving subtitle count metrics come from Yomitan token arrays and stay zero when tokenization is absent.
|
||||||
|
2. Add failing stats UI tests for token-based copy and token-count display helpers.
|
||||||
|
3. Remove `wordsSeen` from active tracker/session/query/type paths and use `tokensSeen` as the single stats count field.
|
||||||
|
4. Update stats UI labels and lookup-rate copy from words to tokens.
|
||||||
|
5. Run targeted verification, then add the changelog fragment and any needed docs update.
|
||||||
|
<!-- SECTION:PLAN:END -->
|
||||||
|
|
||||||
|
## Outcome
|
||||||
|
|
||||||
|
<!-- SECTION:OUTCOME:BEGIN -->
|
||||||
|
Completed. Stats subtitle counts now come directly from Yomitan merged-token counts, `wordsSeen` is removed from the active tracker/storage/query/UI path, token-facing copy is updated, and focused regression coverage plus `bun run typecheck` are green.
|
||||||
|
<!-- SECTION:OUTCOME:END -->
|
||||||
5
changes/2026-03-18-stats-yomitan-token-counts.md
Normal file
5
changes/2026-03-18-stats-yomitan-token-counts.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
type: fixed
|
||||||
|
area: stats
|
||||||
|
|
||||||
|
- Replaced heuristic stats word counts with Yomitan token counts, so session, media, anime, and trend subtitle totals now come directly from parsed subtitle tokens.
|
||||||
|
- Updated stats UI labels and lookup-rate copy to refer to tokens instead of words where those counts are shown.
|
||||||
@@ -16,8 +16,8 @@ Trend charts now consume one chart-oriented backend payload from `/api/stats/tre
|
|||||||
|
|
||||||
- rollup-backed:
|
- rollup-backed:
|
||||||
- activity charts
|
- activity charts
|
||||||
- cumulative watch/cards/words/sessions trends
|
- cumulative watch/cards/tokens/sessions trends
|
||||||
- per-anime watch/cards/words/episodes series
|
- per-anime watch/cards/tokens/episodes series
|
||||||
- session-metric-backed:
|
- session-metric-backed:
|
||||||
- lookup trends
|
- lookup trends
|
||||||
- lookup rate trends
|
- lookup rate trends
|
||||||
@@ -25,6 +25,14 @@ Trend charts now consume one chart-oriented backend payload from `/api/stats/tre
|
|||||||
- vocabulary-backed:
|
- vocabulary-backed:
|
||||||
- new-words trend
|
- new-words trend
|
||||||
|
|
||||||
|
## Metric Semantics
|
||||||
|
|
||||||
|
- subtitle-count stats now use Yomitan merged-token counts as the source of truth
|
||||||
|
- `tokensSeen` is the only active subtitle-count metric in tracker/session/rollup/query paths
|
||||||
|
- no whitespace/CJK-character fallback remains in the live stats path
|
||||||
|
|
||||||
## Contract
|
## Contract
|
||||||
|
|
||||||
The stats UI should treat the trends payload as chart-ready data. Presentation-only work in the client is fine, but rebuilding the main trend datasets from raw sessions should stay out of the render path.
|
The stats UI should treat the trends payload as chart-ready data. Presentation-only work in the client is fine, but rebuilding the main trend datasets from raw sessions should stay out of the render path.
|
||||||
|
|
||||||
|
For session detail timelines, omitting `limit` now means "return the full retained session telemetry/history". Explicit `limit` remains available for bounded callers, but the default stats UI path should not trim long sessions to the newest 200 samples.
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ const SESSION_SUMMARIES = [
|
|||||||
totalWatchedMs: 60_000,
|
totalWatchedMs: 60_000,
|
||||||
activeWatchedMs: 50_000,
|
activeWatchedMs: 50_000,
|
||||||
linesSeen: 10,
|
linesSeen: 10,
|
||||||
wordsSeen: 100,
|
|
||||||
tokensSeen: 80,
|
tokensSeen: 80,
|
||||||
cardsMined: 2,
|
cardsMined: 2,
|
||||||
lookupCount: 5,
|
lookupCount: 5,
|
||||||
@@ -34,11 +33,10 @@ const DAILY_ROLLUPS = [
|
|||||||
totalSessions: 1,
|
totalSessions: 1,
|
||||||
totalActiveMin: 10,
|
totalActiveMin: 10,
|
||||||
totalLinesSeen: 10,
|
totalLinesSeen: 10,
|
||||||
totalWordsSeen: 100,
|
|
||||||
totalTokensSeen: 80,
|
totalTokensSeen: 80,
|
||||||
totalCards: 2,
|
totalCards: 2,
|
||||||
cardsPerHour: 12,
|
cardsPerHour: 12,
|
||||||
wordsPerMin: 10,
|
tokensPerMin: 10,
|
||||||
lookupHitRate: 0.8,
|
lookupHitRate: 0.8,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -96,7 +94,7 @@ const ANIME_LIBRARY = [
|
|||||||
totalSessions: 3,
|
totalSessions: 3,
|
||||||
totalActiveMs: 180_000,
|
totalActiveMs: 180_000,
|
||||||
totalCards: 5,
|
totalCards: 5,
|
||||||
totalWordsSeen: 300,
|
totalTokensSeen: 300,
|
||||||
episodeCount: 2,
|
episodeCount: 2,
|
||||||
episodesTotal: 25,
|
episodesTotal: 25,
|
||||||
lastWatchedMs: Date.now(),
|
lastWatchedMs: Date.now(),
|
||||||
@@ -113,7 +111,7 @@ const ANIME_DETAIL = {
|
|||||||
totalSessions: 3,
|
totalSessions: 3,
|
||||||
totalActiveMs: 180_000,
|
totalActiveMs: 180_000,
|
||||||
totalCards: 5,
|
totalCards: 5,
|
||||||
totalWordsSeen: 300,
|
totalTokensSeen: 300,
|
||||||
totalLinesSeen: 50,
|
totalLinesSeen: 50,
|
||||||
totalLookupCount: 20,
|
totalLookupCount: 20,
|
||||||
totalLookupHits: 15,
|
totalLookupHits: 15,
|
||||||
@@ -198,7 +196,7 @@ const ANIME_EPISODES = [
|
|||||||
totalSessions: 1,
|
totalSessions: 1,
|
||||||
totalActiveMs: 90_000,
|
totalActiveMs: 90_000,
|
||||||
totalCards: 3,
|
totalCards: 3,
|
||||||
totalWordsSeen: 150,
|
totalTokensSeen: 150,
|
||||||
lastWatchedMs: Date.now(),
|
lastWatchedMs: Date.now(),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -349,6 +347,47 @@ describe('stats server API routes', () => {
|
|||||||
assert.ok(Array.isArray(body));
|
assert.ok(Array.isArray(body));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('GET /api/stats/sessions/:id/events forwards event type filters to the tracker', async () => {
|
||||||
|
let seenSessionId = 0;
|
||||||
|
let seenLimit = 0;
|
||||||
|
let seenTypes: number[] | undefined;
|
||||||
|
const app = createStatsApp(
|
||||||
|
createMockTracker({
|
||||||
|
getSessionEvents: async (sessionId: number, limit?: number, eventTypes?: number[]) => {
|
||||||
|
seenSessionId = sessionId;
|
||||||
|
seenLimit = limit ?? 0;
|
||||||
|
seenTypes = eventTypes;
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await app.request('/api/stats/sessions/7/events?limit=12&types=4,5,9');
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
assert.equal(seenSessionId, 7);
|
||||||
|
assert.equal(seenLimit, 12);
|
||||||
|
assert.deepEqual(seenTypes, [4, 5, 9]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET /api/stats/sessions/:id/timeline requests the full session when no limit is provided', async () => {
|
||||||
|
let seenSessionId = 0;
|
||||||
|
let seenLimit: number | undefined;
|
||||||
|
const app = createStatsApp(
|
||||||
|
createMockTracker({
|
||||||
|
getSessionTimeline: async (sessionId: number, limit?: number) => {
|
||||||
|
seenSessionId = sessionId;
|
||||||
|
seenLimit = limit;
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await app.request('/api/stats/sessions/7/timeline');
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
assert.equal(seenSessionId, 7);
|
||||||
|
assert.equal(seenLimit, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
it('GET /api/stats/sessions/:id/known-words-timeline preserves line positions and counts known occurrences', async () => {
|
it('GET /api/stats/sessions/:id/known-words-timeline preserves line positions and counts known occurrences', async () => {
|
||||||
await withTempDir(async (dir) => {
|
await withTempDir(async (dir) => {
|
||||||
const cachePath = path.join(dir, 'known-words.json');
|
const cachePath = path.join(dir, 'known-words.json');
|
||||||
|
|||||||
@@ -218,13 +218,13 @@ test('finalize updates lifetime summary rows from final session metrics', async
|
|||||||
} | null;
|
} | null;
|
||||||
const mediaRow = db
|
const mediaRow = db
|
||||||
.prepare(
|
.prepare(
|
||||||
'SELECT total_sessions, total_cards, total_active_ms, total_words_seen, total_lines_seen FROM imm_lifetime_media WHERE video_id = ?',
|
'SELECT total_sessions, total_cards, total_active_ms, total_tokens_seen, total_lines_seen FROM imm_lifetime_media WHERE video_id = ?',
|
||||||
)
|
)
|
||||||
.get(videoId) as {
|
.get(videoId) as {
|
||||||
total_sessions: number;
|
total_sessions: number;
|
||||||
total_cards: number;
|
total_cards: number;
|
||||||
total_active_ms: number;
|
total_active_ms: number;
|
||||||
total_words_seen: number;
|
total_tokens_seen: number;
|
||||||
total_lines_seen: number;
|
total_lines_seen: number;
|
||||||
} | null;
|
} | null;
|
||||||
const animeIdRow = db
|
const animeIdRow = db
|
||||||
@@ -675,7 +675,6 @@ test('startup finalizes stale active sessions and applies lifetime summaries', a
|
|||||||
total_watched_ms,
|
total_watched_ms,
|
||||||
active_watched_ms,
|
active_watched_ms,
|
||||||
lines_seen,
|
lines_seen,
|
||||||
words_seen,
|
|
||||||
tokens_seen,
|
tokens_seen,
|
||||||
cards_mined,
|
cards_mined,
|
||||||
lookup_count,
|
lookup_count,
|
||||||
@@ -691,7 +690,6 @@ test('startup finalizes stale active sessions and applies lifetime summaries', a
|
|||||||
5000,
|
5000,
|
||||||
4000,
|
4000,
|
||||||
12,
|
12,
|
||||||
90,
|
|
||||||
120,
|
120,
|
||||||
2,
|
2,
|
||||||
5,
|
5,
|
||||||
@@ -711,7 +709,7 @@ test('startup finalizes stale active sessions and applies lifetime summaries', a
|
|||||||
const sessionRow = restartedApi.db
|
const sessionRow = restartedApi.db
|
||||||
.prepare(
|
.prepare(
|
||||||
`
|
`
|
||||||
SELECT ended_at_ms, status, active_watched_ms, words_seen, cards_mined
|
SELECT ended_at_ms, status, active_watched_ms, tokens_seen, cards_mined
|
||||||
FROM imm_sessions
|
FROM imm_sessions
|
||||||
WHERE session_id = 1
|
WHERE session_id = 1
|
||||||
`,
|
`,
|
||||||
@@ -720,7 +718,7 @@ test('startup finalizes stale active sessions and applies lifetime summaries', a
|
|||||||
ended_at_ms: number | null;
|
ended_at_ms: number | null;
|
||||||
status: number;
|
status: number;
|
||||||
active_watched_ms: number;
|
active_watched_ms: number;
|
||||||
words_seen: number;
|
tokens_seen: number;
|
||||||
cards_mined: number;
|
cards_mined: number;
|
||||||
} | null;
|
} | null;
|
||||||
const globalRow = restartedApi.db
|
const globalRow = restartedApi.db
|
||||||
@@ -754,7 +752,7 @@ test('startup finalizes stale active sessions and applies lifetime summaries', a
|
|||||||
assert.ok(Number(sessionRow?.ended_at_ms ?? 0) >= sampleMs);
|
assert.ok(Number(sessionRow?.ended_at_ms ?? 0) >= sampleMs);
|
||||||
assert.equal(sessionRow?.status, 2);
|
assert.equal(sessionRow?.status, 2);
|
||||||
assert.equal(sessionRow?.active_watched_ms, 4000);
|
assert.equal(sessionRow?.active_watched_ms, 4000);
|
||||||
assert.equal(sessionRow?.words_seen, 90);
|
assert.equal(sessionRow?.tokens_seen, 120);
|
||||||
assert.equal(sessionRow?.cards_mined, 2);
|
assert.equal(sessionRow?.cards_mined, 2);
|
||||||
|
|
||||||
assert.ok(globalRow);
|
assert.ok(globalRow);
|
||||||
@@ -782,7 +780,18 @@ test('persists and retrieves minimum immersion tracking fields', async () => {
|
|||||||
tracker = new Ctor({ dbPath });
|
tracker = new Ctor({ dbPath });
|
||||||
|
|
||||||
tracker.handleMediaChange('/tmp/episode-3.mkv', 'Episode 3');
|
tracker.handleMediaChange('/tmp/episode-3.mkv', 'Episode 3');
|
||||||
tracker.recordSubtitleLine('alpha beta', 0, 1.2);
|
tracker.recordSubtitleLine('alpha beta', 0, 1.2, [
|
||||||
|
makeMergedToken({
|
||||||
|
surface: 'alpha',
|
||||||
|
headword: 'alpha',
|
||||||
|
reading: 'alpha',
|
||||||
|
}),
|
||||||
|
makeMergedToken({
|
||||||
|
surface: 'beta',
|
||||||
|
headword: 'beta',
|
||||||
|
reading: 'beta',
|
||||||
|
}),
|
||||||
|
]);
|
||||||
tracker.recordCardsMined(2);
|
tracker.recordCardsMined(2);
|
||||||
tracker.recordLookup(true);
|
tracker.recordLookup(true);
|
||||||
tracker.recordPlaybackPosition(12.5);
|
tracker.recordPlaybackPosition(12.5);
|
||||||
@@ -811,14 +820,13 @@ test('persists and retrieves minimum immersion tracking fields', async () => {
|
|||||||
} | null;
|
} | null;
|
||||||
const telemetryRow = db
|
const telemetryRow = db
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT lines_seen, words_seen, tokens_seen, cards_mined
|
`SELECT lines_seen, tokens_seen, cards_mined
|
||||||
FROM imm_session_telemetry
|
FROM imm_session_telemetry
|
||||||
ORDER BY sample_ms DESC, telemetry_id DESC
|
ORDER BY sample_ms DESC, telemetry_id DESC
|
||||||
LIMIT 1`,
|
LIMIT 1`,
|
||||||
)
|
)
|
||||||
.get() as {
|
.get() as {
|
||||||
lines_seen: number;
|
lines_seen: number;
|
||||||
words_seen: number;
|
|
||||||
tokens_seen: number;
|
tokens_seen: number;
|
||||||
cards_mined: number;
|
cards_mined: number;
|
||||||
} | null;
|
} | null;
|
||||||
@@ -831,7 +839,6 @@ test('persists and retrieves minimum immersion tracking fields', async () => {
|
|||||||
|
|
||||||
assert.ok(telemetryRow);
|
assert.ok(telemetryRow);
|
||||||
assert.ok(Number(telemetryRow?.lines_seen ?? 0) >= 1);
|
assert.ok(Number(telemetryRow?.lines_seen ?? 0) >= 1);
|
||||||
assert.ok(Number(telemetryRow?.words_seen ?? 0) >= 2);
|
|
||||||
assert.ok(Number(telemetryRow?.tokens_seen ?? 0) >= 2);
|
assert.ok(Number(telemetryRow?.tokens_seen ?? 0) >= 2);
|
||||||
assert.ok(Number(telemetryRow?.cards_mined ?? 0) >= 2);
|
assert.ok(Number(telemetryRow?.cards_mined ?? 0) >= 2);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -1062,6 +1069,87 @@ test('recordSubtitleLine persists counted allowed tokenized vocabulary rows and
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('recordSubtitleLine counts exact Yomitan tokens for session metrics', async () => {
|
||||||
|
const dbPath = makeDbPath();
|
||||||
|
let tracker: ImmersionTrackerService | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const Ctor = await loadTrackerCtor();
|
||||||
|
tracker = new Ctor({ dbPath });
|
||||||
|
|
||||||
|
tracker.handleMediaChange('/tmp/token-counting.mkv', 'Token Counting');
|
||||||
|
tracker.recordSubtitleLine('猫 猫 日 日 は 知っている', 0, 1, [
|
||||||
|
makeMergedToken({
|
||||||
|
surface: '猫',
|
||||||
|
headword: '猫',
|
||||||
|
reading: 'ねこ',
|
||||||
|
partOfSpeech: PartOfSpeech.noun,
|
||||||
|
pos1: '名詞',
|
||||||
|
}),
|
||||||
|
makeMergedToken({
|
||||||
|
surface: '猫',
|
||||||
|
headword: '猫',
|
||||||
|
reading: 'ねこ',
|
||||||
|
partOfSpeech: PartOfSpeech.noun,
|
||||||
|
pos1: '名詞',
|
||||||
|
}),
|
||||||
|
makeMergedToken({
|
||||||
|
surface: 'は',
|
||||||
|
headword: 'は',
|
||||||
|
reading: 'は',
|
||||||
|
partOfSpeech: PartOfSpeech.particle,
|
||||||
|
pos1: '助詞',
|
||||||
|
}),
|
||||||
|
makeMergedToken({
|
||||||
|
surface: '知っている',
|
||||||
|
headword: '知る',
|
||||||
|
reading: 'しっている',
|
||||||
|
partOfSpeech: PartOfSpeech.other,
|
||||||
|
pos1: '動詞',
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const privateApi = tracker as unknown as {
|
||||||
|
flushTelemetry: (force?: boolean) => void;
|
||||||
|
flushNow: () => void;
|
||||||
|
};
|
||||||
|
privateApi.flushTelemetry(true);
|
||||||
|
privateApi.flushNow();
|
||||||
|
|
||||||
|
const summaries = await tracker.getSessionSummaries(10);
|
||||||
|
assert.equal(summaries[0]?.tokensSeen, 4);
|
||||||
|
} finally {
|
||||||
|
tracker?.destroy();
|
||||||
|
cleanupDbPath(dbPath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('recordSubtitleLine leaves session token counts at zero when tokenization is unavailable', async () => {
|
||||||
|
const dbPath = makeDbPath();
|
||||||
|
let tracker: ImmersionTrackerService | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const Ctor = await loadTrackerCtor();
|
||||||
|
tracker = new Ctor({ dbPath });
|
||||||
|
|
||||||
|
tracker.handleMediaChange('/tmp/no-tokenization.mkv', 'No Tokenization');
|
||||||
|
tracker.recordSubtitleLine('alpha beta gamma', 0, 1.2, null);
|
||||||
|
|
||||||
|
const privateApi = tracker as unknown as {
|
||||||
|
flushTelemetry: (force?: boolean) => void;
|
||||||
|
flushNow: () => void;
|
||||||
|
};
|
||||||
|
privateApi.flushTelemetry(true);
|
||||||
|
privateApi.flushNow();
|
||||||
|
|
||||||
|
const summaries = await tracker.getSessionSummaries(10);
|
||||||
|
assert.equal(summaries[0]?.tokensSeen, 0);
|
||||||
|
} finally {
|
||||||
|
tracker?.destroy();
|
||||||
|
cleanupDbPath(dbPath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('subtitle-line event payload omits duplicated subtitle text', async () => {
|
test('subtitle-line event payload omits duplicated subtitle text', async () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
let tracker: ImmersionTrackerService | null = null;
|
let tracker: ImmersionTrackerService | null = null;
|
||||||
@@ -1094,11 +1182,11 @@ test('subtitle-line event payload omits duplicated subtitle text', async () => {
|
|||||||
assert.ok(row?.payloadJson);
|
assert.ok(row?.payloadJson);
|
||||||
const parsed = JSON.parse(row?.payloadJson ?? '{}') as {
|
const parsed = JSON.parse(row?.payloadJson ?? '{}') as {
|
||||||
event?: string;
|
event?: string;
|
||||||
words?: number;
|
tokens?: number;
|
||||||
text?: string;
|
text?: string;
|
||||||
};
|
};
|
||||||
assert.equal(parsed.event, 'subtitle-line');
|
assert.equal(parsed.event, 'subtitle-line');
|
||||||
assert.equal(typeof parsed.words, 'number');
|
assert.equal(typeof parsed.tokens, 'number');
|
||||||
assert.equal('text' in parsed, false);
|
assert.equal('text' in parsed, false);
|
||||||
} finally {
|
} finally {
|
||||||
tracker?.destroy();
|
tracker?.destroy();
|
||||||
@@ -1548,12 +1636,11 @@ test('zero retention days disables prune checks while preserving rollups', async
|
|||||||
total_sessions,
|
total_sessions,
|
||||||
total_active_min,
|
total_active_min,
|
||||||
total_lines_seen,
|
total_lines_seen,
|
||||||
total_words_seen,
|
|
||||||
total_tokens_seen,
|
total_tokens_seen,
|
||||||
total_cards
|
total_cards
|
||||||
) VALUES
|
) VALUES
|
||||||
(${insertedDailyRollupKeys[0]}, 1, 1, 1, 1, 1, 1, 1),
|
(${insertedDailyRollupKeys[0]}, 1, 1, 1, 1, 1, 1),
|
||||||
(${insertedDailyRollupKeys[1]}, 1, 1, 1, 1, 1, 1, 1)
|
(${insertedDailyRollupKeys[1]}, 1, 1, 1, 1, 1, 1)
|
||||||
`);
|
`);
|
||||||
privateApi.db.exec(`
|
privateApi.db.exec(`
|
||||||
INSERT INTO imm_monthly_rollups (
|
INSERT INTO imm_monthly_rollups (
|
||||||
@@ -1562,14 +1649,13 @@ test('zero retention days disables prune checks while preserving rollups', async
|
|||||||
total_sessions,
|
total_sessions,
|
||||||
total_active_min,
|
total_active_min,
|
||||||
total_lines_seen,
|
total_lines_seen,
|
||||||
total_words_seen,
|
|
||||||
total_tokens_seen,
|
total_tokens_seen,
|
||||||
total_cards,
|
total_cards,
|
||||||
CREATED_DATE,
|
CREATED_DATE,
|
||||||
LAST_UPDATE_DATE
|
LAST_UPDATE_DATE
|
||||||
) VALUES
|
) VALUES
|
||||||
(${insertedMonthlyRollupKeys[0]}, 1, 1, 1, 1, 1, 1, 1, ${olderMs}, ${olderMs}),
|
(${insertedMonthlyRollupKeys[0]}, 1, 1, 1, 1, 1, 1, ${olderMs}, ${olderMs}),
|
||||||
(${insertedMonthlyRollupKeys[1]}, 1, 1, 1, 1, 1, 1, 1, ${oldMs}, ${oldMs})
|
(${insertedMonthlyRollupKeys[1]}, 1, 1, 1, 1, 1, 1, ${oldMs}, ${oldMs})
|
||||||
`);
|
`);
|
||||||
|
|
||||||
privateApi.runMaintenance();
|
privateApi.runMaintenance();
|
||||||
@@ -1668,7 +1754,6 @@ test('monthly rollups are grouped by calendar month', async () => {
|
|||||||
total_watched_ms,
|
total_watched_ms,
|
||||||
active_watched_ms,
|
active_watched_ms,
|
||||||
lines_seen,
|
lines_seen,
|
||||||
words_seen,
|
|
||||||
tokens_seen,
|
tokens_seen,
|
||||||
cards_mined,
|
cards_mined,
|
||||||
lookup_count,
|
lookup_count,
|
||||||
@@ -1685,7 +1770,6 @@ test('monthly rollups are grouped by calendar month', async () => {
|
|||||||
5000,
|
5000,
|
||||||
1,
|
1,
|
||||||
2,
|
2,
|
||||||
2,
|
|
||||||
0,
|
0,
|
||||||
0,
|
0,
|
||||||
0,
|
0,
|
||||||
@@ -1725,7 +1809,6 @@ test('monthly rollups are grouped by calendar month', async () => {
|
|||||||
total_watched_ms,
|
total_watched_ms,
|
||||||
active_watched_ms,
|
active_watched_ms,
|
||||||
lines_seen,
|
lines_seen,
|
||||||
words_seen,
|
|
||||||
tokens_seen,
|
tokens_seen,
|
||||||
cards_mined,
|
cards_mined,
|
||||||
lookup_count,
|
lookup_count,
|
||||||
@@ -1742,7 +1825,6 @@ test('monthly rollups are grouped by calendar month', async () => {
|
|||||||
4000,
|
4000,
|
||||||
2,
|
2,
|
||||||
3,
|
3,
|
||||||
3,
|
|
||||||
1,
|
1,
|
||||||
1,
|
1,
|
||||||
1,
|
1,
|
||||||
@@ -1786,13 +1868,12 @@ test('flushSingle reuses cached prepared statements', async () => {
|
|||||||
lineIndex?: number | null;
|
lineIndex?: number | null;
|
||||||
segmentStartMs?: number | null;
|
segmentStartMs?: number | null;
|
||||||
segmentEndMs?: number | null;
|
segmentEndMs?: number | null;
|
||||||
wordsDelta?: number;
|
tokensDelta?: number;
|
||||||
cardsDelta?: number;
|
cardsDelta?: number;
|
||||||
payloadJson?: string | null;
|
payloadJson?: string | null;
|
||||||
totalWatchedMs?: number;
|
totalWatchedMs?: number;
|
||||||
activeWatchedMs?: number;
|
activeWatchedMs?: number;
|
||||||
linesSeen?: number;
|
linesSeen?: number;
|
||||||
wordsSeen?: number;
|
|
||||||
tokensSeen?: number;
|
tokensSeen?: number;
|
||||||
cardsMined?: number;
|
cardsMined?: number;
|
||||||
lookupCount?: number;
|
lookupCount?: number;
|
||||||
@@ -1862,7 +1943,6 @@ test('flushSingle reuses cached prepared statements', async () => {
|
|||||||
totalWatchedMs: 1000,
|
totalWatchedMs: 1000,
|
||||||
activeWatchedMs: 1000,
|
activeWatchedMs: 1000,
|
||||||
linesSeen: 1,
|
linesSeen: 1,
|
||||||
wordsSeen: 2,
|
|
||||||
tokensSeen: 2,
|
tokensSeen: 2,
|
||||||
cardsMined: 0,
|
cardsMined: 0,
|
||||||
lookupCount: 0,
|
lookupCount: 0,
|
||||||
@@ -1882,7 +1962,7 @@ test('flushSingle reuses cached prepared statements', async () => {
|
|||||||
lineIndex: 1,
|
lineIndex: 1,
|
||||||
segmentStartMs: 0,
|
segmentStartMs: 0,
|
||||||
segmentEndMs: 1000,
|
segmentEndMs: 1000,
|
||||||
wordsDelta: 2,
|
tokensDelta: 2,
|
||||||
cardsDelta: 0,
|
cardsDelta: 0,
|
||||||
payloadJson: '{"event":"subtitle-line"}',
|
payloadJson: '{"event":"subtitle-line"}',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -80,7 +80,6 @@ import {
|
|||||||
} from './immersion-tracker/query';
|
} from './immersion-tracker/query';
|
||||||
import {
|
import {
|
||||||
buildVideoKey,
|
buildVideoKey,
|
||||||
calculateTextMetrics,
|
|
||||||
deriveCanonicalTitle,
|
deriveCanonicalTitle,
|
||||||
isKanji,
|
isKanji,
|
||||||
isRemoteSource,
|
isRemoteSource,
|
||||||
@@ -334,7 +333,7 @@ export class ImmersionTrackerService {
|
|||||||
return getSessionSummaries(this.db, limit);
|
return getSessionSummaries(this.db, limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getSessionTimeline(sessionId: number, limit = 200): Promise<SessionTimelineRow[]> {
|
async getSessionTimeline(sessionId: number, limit?: number): Promise<SessionTimelineRow[]> {
|
||||||
return getSessionTimeline(this.db, sessionId, limit);
|
return getSessionTimeline(this.db, sessionId, limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -419,8 +418,12 @@ export class ImmersionTrackerService {
|
|||||||
return getKanjiOccurrences(this.db, kanji, limit, offset);
|
return getKanjiOccurrences(this.db, kanji, limit, offset);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getSessionEvents(sessionId: number, limit = 500): Promise<SessionEventRow[]> {
|
async getSessionEvents(
|
||||||
return getSessionEvents(this.db, sessionId, limit);
|
sessionId: number,
|
||||||
|
limit = 500,
|
||||||
|
eventTypes?: number[],
|
||||||
|
): Promise<SessionEventRow[]> {
|
||||||
|
return getSessionEvents(this.db, sessionId, limit, eventTypes);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getMediaLibrary(): Promise<MediaLibraryRow[]> {
|
async getMediaLibrary(): Promise<MediaLibraryRow[]> {
|
||||||
@@ -747,11 +750,10 @@ export class ImmersionTrackerService {
|
|||||||
const nowMs = Date.now();
|
const nowMs = Date.now();
|
||||||
const nowSec = nowMs / 1000;
|
const nowSec = nowMs / 1000;
|
||||||
|
|
||||||
const metrics = calculateTextMetrics(cleaned);
|
const tokenCount = tokens?.length ?? 0;
|
||||||
this.sessionState.currentLineIndex += 1;
|
this.sessionState.currentLineIndex += 1;
|
||||||
this.sessionState.linesSeen += 1;
|
this.sessionState.linesSeen += 1;
|
||||||
this.sessionState.wordsSeen += metrics.words;
|
this.sessionState.tokensSeen += tokenCount;
|
||||||
this.sessionState.tokensSeen += metrics.tokens;
|
|
||||||
this.sessionState.pendingTelemetry = true;
|
this.sessionState.pendingTelemetry = true;
|
||||||
|
|
||||||
const wordOccurrences = new Map<string, CountedWordOccurrence>();
|
const wordOccurrences = new Map<string, CountedWordOccurrence>();
|
||||||
@@ -821,13 +823,13 @@ export class ImmersionTrackerService {
|
|||||||
lineIndex: this.sessionState.currentLineIndex,
|
lineIndex: this.sessionState.currentLineIndex,
|
||||||
segmentStartMs: secToMs(startSec),
|
segmentStartMs: secToMs(startSec),
|
||||||
segmentEndMs: secToMs(endSec),
|
segmentEndMs: secToMs(endSec),
|
||||||
wordsDelta: metrics.words,
|
tokensDelta: tokenCount,
|
||||||
cardsDelta: 0,
|
cardsDelta: 0,
|
||||||
eventType: EVENT_SUBTITLE_LINE,
|
eventType: EVENT_SUBTITLE_LINE,
|
||||||
payloadJson: sanitizePayload(
|
payloadJson: sanitizePayload(
|
||||||
{
|
{
|
||||||
event: 'subtitle-line',
|
event: 'subtitle-line',
|
||||||
words: metrics.words,
|
tokens: tokenCount,
|
||||||
},
|
},
|
||||||
this.maxPayloadBytes,
|
this.maxPayloadBytes,
|
||||||
),
|
),
|
||||||
@@ -876,7 +878,7 @@ export class ImmersionTrackerService {
|
|||||||
sessionId: this.sessionState.sessionId,
|
sessionId: this.sessionState.sessionId,
|
||||||
sampleMs: nowMs,
|
sampleMs: nowMs,
|
||||||
eventType: EVENT_SEEK_FORWARD,
|
eventType: EVENT_SEEK_FORWARD,
|
||||||
wordsDelta: 0,
|
tokensDelta: 0,
|
||||||
cardsDelta: 0,
|
cardsDelta: 0,
|
||||||
segmentStartMs: this.sessionState.lastMediaMs,
|
segmentStartMs: this.sessionState.lastMediaMs,
|
||||||
segmentEndMs: mediaMs,
|
segmentEndMs: mediaMs,
|
||||||
@@ -896,7 +898,7 @@ export class ImmersionTrackerService {
|
|||||||
sessionId: this.sessionState.sessionId,
|
sessionId: this.sessionState.sessionId,
|
||||||
sampleMs: nowMs,
|
sampleMs: nowMs,
|
||||||
eventType: EVENT_SEEK_BACKWARD,
|
eventType: EVENT_SEEK_BACKWARD,
|
||||||
wordsDelta: 0,
|
tokensDelta: 0,
|
||||||
cardsDelta: 0,
|
cardsDelta: 0,
|
||||||
segmentStartMs: this.sessionState.lastMediaMs,
|
segmentStartMs: this.sessionState.lastMediaMs,
|
||||||
segmentEndMs: mediaMs,
|
segmentEndMs: mediaMs,
|
||||||
@@ -940,7 +942,7 @@ export class ImmersionTrackerService {
|
|||||||
sampleMs: nowMs,
|
sampleMs: nowMs,
|
||||||
eventType: EVENT_PAUSE_START,
|
eventType: EVENT_PAUSE_START,
|
||||||
cardsDelta: 0,
|
cardsDelta: 0,
|
||||||
wordsDelta: 0,
|
tokensDelta: 0,
|
||||||
payloadJson: sanitizePayload({ paused: true }, this.maxPayloadBytes),
|
payloadJson: sanitizePayload({ paused: true }, this.maxPayloadBytes),
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@@ -955,7 +957,7 @@ export class ImmersionTrackerService {
|
|||||||
sampleMs: nowMs,
|
sampleMs: nowMs,
|
||||||
eventType: EVENT_PAUSE_END,
|
eventType: EVENT_PAUSE_END,
|
||||||
cardsDelta: 0,
|
cardsDelta: 0,
|
||||||
wordsDelta: 0,
|
tokensDelta: 0,
|
||||||
payloadJson: sanitizePayload({ paused: false }, this.maxPayloadBytes),
|
payloadJson: sanitizePayload({ paused: false }, this.maxPayloadBytes),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -976,7 +978,7 @@ export class ImmersionTrackerService {
|
|||||||
sampleMs: Date.now(),
|
sampleMs: Date.now(),
|
||||||
eventType: EVENT_LOOKUP,
|
eventType: EVENT_LOOKUP,
|
||||||
cardsDelta: 0,
|
cardsDelta: 0,
|
||||||
wordsDelta: 0,
|
tokensDelta: 0,
|
||||||
payloadJson: sanitizePayload(
|
payloadJson: sanitizePayload(
|
||||||
{
|
{
|
||||||
hit,
|
hit,
|
||||||
@@ -996,7 +998,7 @@ export class ImmersionTrackerService {
|
|||||||
sampleMs: Date.now(),
|
sampleMs: Date.now(),
|
||||||
eventType: EVENT_YOMITAN_LOOKUP,
|
eventType: EVENT_YOMITAN_LOOKUP,
|
||||||
cardsDelta: 0,
|
cardsDelta: 0,
|
||||||
wordsDelta: 0,
|
tokensDelta: 0,
|
||||||
payloadJson: null,
|
payloadJson: null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1010,7 +1012,7 @@ export class ImmersionTrackerService {
|
|||||||
sessionId: this.sessionState.sessionId,
|
sessionId: this.sessionState.sessionId,
|
||||||
sampleMs: Date.now(),
|
sampleMs: Date.now(),
|
||||||
eventType: EVENT_CARD_MINED,
|
eventType: EVENT_CARD_MINED,
|
||||||
wordsDelta: 0,
|
tokensDelta: 0,
|
||||||
cardsDelta: count,
|
cardsDelta: count,
|
||||||
payloadJson: sanitizePayload(
|
payloadJson: sanitizePayload(
|
||||||
{ cardsMined: count, ...(noteIds?.length ? { noteIds } : {}) },
|
{ cardsMined: count, ...(noteIds?.length ? { noteIds } : {}) },
|
||||||
@@ -1029,7 +1031,7 @@ export class ImmersionTrackerService {
|
|||||||
sampleMs: Date.now(),
|
sampleMs: Date.now(),
|
||||||
eventType: EVENT_MEDIA_BUFFER,
|
eventType: EVENT_MEDIA_BUFFER,
|
||||||
cardsDelta: 0,
|
cardsDelta: 0,
|
||||||
wordsDelta: 0,
|
tokensDelta: 0,
|
||||||
payloadJson: sanitizePayload(
|
payloadJson: sanitizePayload(
|
||||||
{
|
{
|
||||||
buffer: true,
|
buffer: true,
|
||||||
@@ -1062,7 +1064,6 @@ export class ImmersionTrackerService {
|
|||||||
totalWatchedMs: this.sessionState.totalWatchedMs,
|
totalWatchedMs: this.sessionState.totalWatchedMs,
|
||||||
activeWatchedMs: this.sessionState.activeWatchedMs,
|
activeWatchedMs: this.sessionState.activeWatchedMs,
|
||||||
linesSeen: this.sessionState.linesSeen,
|
linesSeen: this.sessionState.linesSeen,
|
||||||
wordsSeen: this.sessionState.wordsSeen,
|
|
||||||
tokensSeen: this.sessionState.tokensSeen,
|
tokensSeen: this.sessionState.tokensSeen,
|
||||||
cardsMined: this.sessionState.cardsMined,
|
cardsMined: this.sessionState.cardsMined,
|
||||||
lookupCount: this.sessionState.lookupCount,
|
lookupCount: this.sessionState.lookupCount,
|
||||||
@@ -1191,7 +1192,6 @@ export class ImmersionTrackerService {
|
|||||||
totalWatchedMs: 0,
|
totalWatchedMs: 0,
|
||||||
activeWatchedMs: 0,
|
activeWatchedMs: 0,
|
||||||
linesSeen: 0,
|
linesSeen: 0,
|
||||||
wordsSeen: 0,
|
|
||||||
tokensSeen: 0,
|
tokensSeen: 0,
|
||||||
cardsMined: 0,
|
cardsMined: 0,
|
||||||
lookupCount: 0,
|
lookupCount: 0,
|
||||||
|
|||||||
@@ -32,11 +32,17 @@ import {
|
|||||||
getVocabularyStats,
|
getVocabularyStats,
|
||||||
getKanjiStats,
|
getKanjiStats,
|
||||||
getSessionEvents,
|
getSessionEvents,
|
||||||
|
getSessionTimeline,
|
||||||
getSessionWordsByLine,
|
getSessionWordsByLine,
|
||||||
getWordOccurrences,
|
getWordOccurrences,
|
||||||
upsertCoverArt,
|
upsertCoverArt,
|
||||||
} from '../query.js';
|
} from '../query.js';
|
||||||
import { SOURCE_TYPE_LOCAL, EVENT_SUBTITLE_LINE } from '../types.js';
|
import {
|
||||||
|
SOURCE_TYPE_LOCAL,
|
||||||
|
EVENT_CARD_MINED,
|
||||||
|
EVENT_SUBTITLE_LINE,
|
||||||
|
EVENT_YOMITAN_LOOKUP,
|
||||||
|
} from '../types.js';
|
||||||
|
|
||||||
function makeDbPath(): string {
|
function makeDbPath(): string {
|
||||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-imm-query-test-'));
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-imm-query-test-'));
|
||||||
@@ -99,7 +105,6 @@ test('getSessionSummaries returns sessionId and canonicalTitle', () => {
|
|||||||
2_500,
|
2_500,
|
||||||
5,
|
5,
|
||||||
10,
|
10,
|
||||||
10,
|
|
||||||
1,
|
1,
|
||||||
2,
|
2,
|
||||||
1,
|
1,
|
||||||
@@ -124,7 +129,6 @@ test('getSessionSummaries returns sessionId and canonicalTitle', () => {
|
|||||||
assert.equal(row.linesSeen, 5);
|
assert.equal(row.linesSeen, 5);
|
||||||
assert.equal(row.totalWatchedMs, 3_000);
|
assert.equal(row.totalWatchedMs, 3_000);
|
||||||
assert.equal(row.activeWatchedMs, 2_500);
|
assert.equal(row.activeWatchedMs, 2_500);
|
||||||
assert.equal(row.wordsSeen, 10);
|
|
||||||
assert.equal(row.tokensSeen, 10);
|
assert.equal(row.tokensSeen, 10);
|
||||||
assert.equal(row.lookupCount, 2);
|
assert.equal(row.lookupCount, 2);
|
||||||
assert.equal(row.lookupHits, 1);
|
assert.equal(row.lookupHits, 1);
|
||||||
@@ -135,6 +139,57 @@ test('getSessionSummaries returns sessionId and canonicalTitle', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('getSessionTimeline returns the full session when no limit is provided', () => {
|
||||||
|
const dbPath = makeDbPath();
|
||||||
|
const db = new Database(dbPath);
|
||||||
|
|
||||||
|
try {
|
||||||
|
ensureSchema(db);
|
||||||
|
const stmts = createTrackerPreparedStatements(db);
|
||||||
|
|
||||||
|
const videoId = getOrCreateVideoRecord(db, 'local:/tmp/full-timeline-test.mkv', {
|
||||||
|
canonicalTitle: 'Full Timeline Test',
|
||||||
|
sourcePath: '/tmp/full-timeline-test.mkv',
|
||||||
|
sourceUrl: null,
|
||||||
|
sourceType: SOURCE_TYPE_LOCAL,
|
||||||
|
});
|
||||||
|
|
||||||
|
const startedAtMs = 2_000_000;
|
||||||
|
const { sessionId } = startSessionRecord(db, videoId, startedAtMs);
|
||||||
|
|
||||||
|
for (let sample = 0; sample < 205; sample += 1) {
|
||||||
|
const sampleMs = startedAtMs + sample * 500;
|
||||||
|
stmts.telemetryInsertStmt.run(
|
||||||
|
sessionId,
|
||||||
|
sampleMs,
|
||||||
|
sample * 500,
|
||||||
|
sample * 450,
|
||||||
|
sample,
|
||||||
|
sample * 4,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
sampleMs,
|
||||||
|
sampleMs,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = getSessionTimeline(db, sessionId);
|
||||||
|
|
||||||
|
assert.equal(rows.length, 205);
|
||||||
|
assert.equal(rows[0]?.linesSeen, 204);
|
||||||
|
assert.equal(rows.at(-1)?.linesSeen, 0);
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
cleanupDbPath(dbPath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('getDailyRollups limits by distinct days (not rows)', () => {
|
test('getDailyRollups limits by distinct days (not rows)', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = new Database(dbPath);
|
const db = new Database(dbPath);
|
||||||
@@ -146,15 +201,15 @@ test('getDailyRollups limits by distinct days (not rows)', () => {
|
|||||||
`
|
`
|
||||||
INSERT INTO imm_daily_rollups (
|
INSERT INTO imm_daily_rollups (
|
||||||
rollup_day, video_id, total_sessions, total_active_min, total_lines_seen,
|
rollup_day, video_id, total_sessions, total_active_min, total_lines_seen,
|
||||||
total_words_seen, total_tokens_seen, total_cards
|
total_tokens_seen, total_cards
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
`,
|
`,
|
||||||
);
|
);
|
||||||
|
|
||||||
insert.run(10, 1, 1, 1, 0, 0, 0, 2);
|
insert.run(10, 1, 1, 1, 0, 0, 2);
|
||||||
insert.run(10, 2, 1, 1, 0, 0, 0, 3);
|
insert.run(10, 2, 1, 1, 0, 0, 3);
|
||||||
insert.run(9, 1, 1, 1, 0, 0, 0, 1);
|
insert.run(9, 1, 1, 1, 0, 0, 1);
|
||||||
insert.run(8, 1, 1, 1, 0, 0, 0, 1);
|
insert.run(8, 1, 1, 1, 0, 0, 1);
|
||||||
|
|
||||||
const rows = getDailyRollups(db, 2);
|
const rows = getDailyRollups(db, 2);
|
||||||
assert.equal(rows.length, 3);
|
assert.equal(rows.length, 3);
|
||||||
@@ -213,12 +268,11 @@ test('getTrendsDashboard returns chart-ready aggregated series', () => {
|
|||||||
startedAtMs,
|
startedAtMs,
|
||||||
activeWatchedMs,
|
activeWatchedMs,
|
||||||
cardsMined,
|
cardsMined,
|
||||||
wordsSeen,
|
|
||||||
tokensSeen,
|
tokensSeen,
|
||||||
yomitanLookupCount,
|
yomitanLookupCount,
|
||||||
] of [
|
] of [
|
||||||
[sessionOne.sessionId, dayOneStart, 30 * 60_000, 2, 100, 120, 8],
|
[sessionOne.sessionId, dayOneStart, 30 * 60_000, 2, 120, 8],
|
||||||
[sessionTwo.sessionId, dayTwoStart, 45 * 60_000, 3, 120, 140, 10],
|
[sessionTwo.sessionId, dayTwoStart, 45 * 60_000, 3, 140, 10],
|
||||||
] as const) {
|
] as const) {
|
||||||
stmts.telemetryInsertStmt.run(
|
stmts.telemetryInsertStmt.run(
|
||||||
sessionId,
|
sessionId,
|
||||||
@@ -226,7 +280,6 @@ test('getTrendsDashboard returns chart-ready aggregated series', () => {
|
|||||||
activeWatchedMs,
|
activeWatchedMs,
|
||||||
activeWatchedMs,
|
activeWatchedMs,
|
||||||
10,
|
10,
|
||||||
wordsSeen,
|
|
||||||
tokensSeen,
|
tokensSeen,
|
||||||
cardsMined,
|
cardsMined,
|
||||||
0,
|
0,
|
||||||
@@ -248,7 +301,6 @@ test('getTrendsDashboard returns chart-ready aggregated series', () => {
|
|||||||
total_watched_ms = ?,
|
total_watched_ms = ?,
|
||||||
active_watched_ms = ?,
|
active_watched_ms = ?,
|
||||||
lines_seen = ?,
|
lines_seen = ?,
|
||||||
words_seen = ?,
|
|
||||||
tokens_seen = ?,
|
tokens_seen = ?,
|
||||||
cards_mined = ?,
|
cards_mined = ?,
|
||||||
yomitan_lookup_count = ?
|
yomitan_lookup_count = ?
|
||||||
@@ -259,7 +311,6 @@ test('getTrendsDashboard returns chart-ready aggregated series', () => {
|
|||||||
activeWatchedMs,
|
activeWatchedMs,
|
||||||
activeWatchedMs,
|
activeWatchedMs,
|
||||||
10,
|
10,
|
||||||
wordsSeen,
|
|
||||||
tokensSeen,
|
tokensSeen,
|
||||||
cardsMined,
|
cardsMined,
|
||||||
yomitanLookupCount,
|
yomitanLookupCount,
|
||||||
@@ -271,19 +322,19 @@ test('getTrendsDashboard returns chart-ready aggregated series', () => {
|
|||||||
`
|
`
|
||||||
INSERT INTO imm_daily_rollups (
|
INSERT INTO imm_daily_rollups (
|
||||||
rollup_day, video_id, total_sessions, total_active_min, total_lines_seen,
|
rollup_day, video_id, total_sessions, total_active_min, total_lines_seen,
|
||||||
total_words_seen, total_tokens_seen, total_cards
|
total_tokens_seen, total_cards
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
`,
|
`,
|
||||||
).run(Math.floor(dayOneStart / 86_400_000), videoId, 1, 30, 10, 100, 120, 2);
|
).run(Math.floor(dayOneStart / 86_400_000), videoId, 1, 30, 10, 120, 2);
|
||||||
|
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`
|
`
|
||||||
INSERT INTO imm_daily_rollups (
|
INSERT INTO imm_daily_rollups (
|
||||||
rollup_day, video_id, total_sessions, total_active_min, total_lines_seen,
|
rollup_day, video_id, total_sessions, total_active_min, total_lines_seen,
|
||||||
total_words_seen, total_tokens_seen, total_cards
|
total_tokens_seen, total_cards
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
`,
|
`,
|
||||||
).run(Math.floor(dayTwoStart / 86_400_000), videoId, 1, 45, 10, 120, 140, 3);
|
).run(Math.floor(dayTwoStart / 86_400_000), videoId, 1, 45, 10, 140, 3);
|
||||||
|
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`
|
`
|
||||||
@@ -349,14 +400,14 @@ test('getQueryHints reads all-time totals from lifetime summary', () => {
|
|||||||
`
|
`
|
||||||
INSERT INTO imm_daily_rollups (
|
INSERT INTO imm_daily_rollups (
|
||||||
rollup_day, video_id, total_sessions, total_active_min, total_lines_seen,
|
rollup_day, video_id, total_sessions, total_active_min, total_lines_seen,
|
||||||
total_words_seen, total_tokens_seen, total_cards
|
total_tokens_seen, total_cards
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
`,
|
`,
|
||||||
);
|
);
|
||||||
|
|
||||||
insert.run(10, 1, 1, 12, 0, 0, 0, 2);
|
insert.run(10, 1, 1, 12, 0, 0, 2);
|
||||||
insert.run(10, 2, 1, 11, 0, 0, 0, 3);
|
insert.run(10, 2, 1, 11, 0, 0, 3);
|
||||||
insert.run(9, 1, 1, 10, 0, 0, 0, 1);
|
insert.run(9, 1, 1, 10, 0, 0, 1);
|
||||||
|
|
||||||
const hints = getQueryHints(db);
|
const hints = getQueryHints(db);
|
||||||
assert.equal(hints.totalSessions, 4);
|
assert.equal(hints.totalSessions, 4);
|
||||||
@@ -394,7 +445,6 @@ test('getSessionSummaries with no telemetry returns zero aggregates', () => {
|
|||||||
assert.equal(row.totalWatchedMs, 0);
|
assert.equal(row.totalWatchedMs, 0);
|
||||||
assert.equal(row.activeWatchedMs, 0);
|
assert.equal(row.activeWatchedMs, 0);
|
||||||
assert.equal(row.linesSeen, 0);
|
assert.equal(row.linesSeen, 0);
|
||||||
assert.equal(row.wordsSeen, 0);
|
|
||||||
assert.equal(row.tokensSeen, 0);
|
assert.equal(row.tokensSeen, 0);
|
||||||
assert.equal(row.lookupCount, 0);
|
assert.equal(row.lookupCount, 0);
|
||||||
assert.equal(row.lookupHits, 0);
|
assert.equal(row.lookupHits, 0);
|
||||||
@@ -432,7 +482,6 @@ test('getSessionSummaries uses denormalized session metrics for ended sessions w
|
|||||||
total_watched_ms = ?,
|
total_watched_ms = ?,
|
||||||
active_watched_ms = ?,
|
active_watched_ms = ?,
|
||||||
lines_seen = ?,
|
lines_seen = ?,
|
||||||
words_seen = ?,
|
|
||||||
tokens_seen = ?,
|
tokens_seen = ?,
|
||||||
cards_mined = ?,
|
cards_mined = ?,
|
||||||
lookup_count = ?,
|
lookup_count = ?,
|
||||||
@@ -440,7 +489,7 @@ test('getSessionSummaries uses denormalized session metrics for ended sessions w
|
|||||||
LAST_UPDATE_DATE = ?
|
LAST_UPDATE_DATE = ?
|
||||||
WHERE session_id = ?
|
WHERE session_id = ?
|
||||||
`,
|
`,
|
||||||
).run(endedAtMs, 8_000, 7_000, 12, 34, 34, 5, 9, 6, endedAtMs, sessionId);
|
).run(endedAtMs, 8_000, 7_000, 12, 34, 5, 9, 6, endedAtMs, sessionId);
|
||||||
|
|
||||||
const rows = getSessionSummaries(db, 10);
|
const rows = getSessionSummaries(db, 10);
|
||||||
const row = rows.find((r) => r.sessionId === sessionId);
|
const row = rows.find((r) => r.sessionId === sessionId);
|
||||||
@@ -448,7 +497,6 @@ test('getSessionSummaries uses denormalized session metrics for ended sessions w
|
|||||||
assert.equal(row.totalWatchedMs, 8_000);
|
assert.equal(row.totalWatchedMs, 8_000);
|
||||||
assert.equal(row.activeWatchedMs, 7_000);
|
assert.equal(row.activeWatchedMs, 7_000);
|
||||||
assert.equal(row.linesSeen, 12);
|
assert.equal(row.linesSeen, 12);
|
||||||
assert.equal(row.wordsSeen, 34);
|
|
||||||
assert.equal(row.tokensSeen, 34);
|
assert.equal(row.tokensSeen, 34);
|
||||||
assert.equal(row.cardsMined, 5);
|
assert.equal(row.cardsMined, 5);
|
||||||
assert.equal(row.lookupCount, 9);
|
assert.equal(row.lookupCount, 9);
|
||||||
@@ -639,15 +687,15 @@ test('getDailyRollups returns all rows for the most recent rollup days', () => {
|
|||||||
const insertRollup = db.prepare(
|
const insertRollup = db.prepare(
|
||||||
`
|
`
|
||||||
INSERT INTO imm_daily_rollups (
|
INSERT INTO imm_daily_rollups (
|
||||||
rollup_day, video_id, total_sessions, total_active_min, total_lines_seen, total_words_seen,
|
rollup_day, video_id, total_sessions, total_active_min, total_lines_seen,
|
||||||
total_tokens_seen, total_cards, cards_per_hour, words_per_min, lookup_hit_rate
|
total_tokens_seen, total_cards, cards_per_hour, tokens_per_min, lookup_hit_rate
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`,
|
`,
|
||||||
);
|
);
|
||||||
insertRollup.run(3_000, 1, 1, 10, 20, 30, 40, 2, 0.1, 0.2, 0.3);
|
insertRollup.run(3_000, 1, 1, 10, 20, 40, 2, 0.1, 0.2, 0.3);
|
||||||
insertRollup.run(3_000, 2, 2, 10, 20, 30, 40, 3, 0.1, 0.2, 0.3);
|
insertRollup.run(3_000, 2, 2, 10, 20, 40, 3, 0.1, 0.2, 0.3);
|
||||||
insertRollup.run(2_999, 3, 1, 5, 10, 15, 20, 1, 0.1, 0.2, 0.3);
|
insertRollup.run(2_999, 3, 1, 5, 10, 20, 1, 0.1, 0.2, 0.3);
|
||||||
insertRollup.run(2_998, 4, 1, 5, 10, 15, 20, 1, 0.1, 0.2, 0.3);
|
insertRollup.run(2_998, 4, 1, 5, 10, 20, 1, 0.1, 0.2, 0.3);
|
||||||
|
|
||||||
const rows = getDailyRollups(db, 1);
|
const rows = getDailyRollups(db, 1);
|
||||||
assert.equal(rows.length, 2);
|
assert.equal(rows.length, 2);
|
||||||
@@ -675,16 +723,16 @@ test('getMonthlyRollups returns all rows for the most recent rollup months', ()
|
|||||||
const insertRollup = db.prepare(
|
const insertRollup = db.prepare(
|
||||||
`
|
`
|
||||||
INSERT INTO imm_monthly_rollups (
|
INSERT INTO imm_monthly_rollups (
|
||||||
rollup_month, video_id, total_sessions, total_active_min, total_lines_seen, total_words_seen,
|
rollup_month, video_id, total_sessions, total_active_min, total_lines_seen,
|
||||||
total_tokens_seen, total_cards, CREATED_DATE, LAST_UPDATE_DATE
|
total_tokens_seen, total_cards, CREATED_DATE, LAST_UPDATE_DATE
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`,
|
`,
|
||||||
);
|
);
|
||||||
const nowMs = Date.now();
|
const nowMs = Date.now();
|
||||||
insertRollup.run(202602, 1, 1, 10, 20, 30, 40, 5, nowMs, nowMs);
|
insertRollup.run(202602, 1, 1, 10, 20, 40, 5, nowMs, nowMs);
|
||||||
insertRollup.run(202602, 2, 1, 10, 20, 30, 40, 6, nowMs, nowMs);
|
insertRollup.run(202602, 2, 1, 10, 20, 40, 6, nowMs, nowMs);
|
||||||
insertRollup.run(202601, 3, 1, 5, 10, 15, 20, 2, nowMs, nowMs);
|
insertRollup.run(202601, 3, 1, 5, 10, 20, 2, nowMs, nowMs);
|
||||||
insertRollup.run(202600, 4, 1, 5, 10, 15, 20, 2, nowMs, nowMs);
|
insertRollup.run(202600, 4, 1, 5, 10, 20, 2, nowMs, nowMs);
|
||||||
|
|
||||||
const rows = getMonthlyRollups(db, 1);
|
const rows = getMonthlyRollups(db, 1);
|
||||||
assert.equal(rows.length, 2);
|
assert.equal(rows.length, 2);
|
||||||
@@ -706,9 +754,9 @@ test('getAnimeDailyRollups returns all rows for the most recent rollup days', ()
|
|||||||
const insertRollup = db.prepare(
|
const insertRollup = db.prepare(
|
||||||
`
|
`
|
||||||
INSERT INTO imm_daily_rollups (
|
INSERT INTO imm_daily_rollups (
|
||||||
rollup_day, video_id, total_sessions, total_active_min, total_lines_seen, total_words_seen,
|
rollup_day, video_id, total_sessions, total_active_min, total_lines_seen,
|
||||||
total_tokens_seen, total_cards, cards_per_hour, words_per_min, lookup_hit_rate
|
total_tokens_seen, total_cards, cards_per_hour, tokens_per_min, lookup_hit_rate
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`,
|
`,
|
||||||
);
|
);
|
||||||
const animeId = getOrCreateAnimeRecord(db, {
|
const animeId = getOrCreateAnimeRecord(db, {
|
||||||
@@ -738,9 +786,9 @@ test('getAnimeDailyRollups returns all rows for the most recent rollup days', ()
|
|||||||
video2,
|
video2,
|
||||||
);
|
);
|
||||||
|
|
||||||
insertRollup.run(4_000, video1, 1, 10, 20, 30, 40, 2, 0.1, 0.2, 0.3);
|
insertRollup.run(4_000, video1, 1, 10, 20, 40, 2, 0.1, 0.2, 0.3);
|
||||||
insertRollup.run(4_000, video2, 1, 10, 20, 30, 40, 2, 0.1, 0.2, 0.3);
|
insertRollup.run(4_000, video2, 1, 10, 20, 40, 2, 0.1, 0.2, 0.3);
|
||||||
insertRollup.run(3_999, video1, 1, 10, 20, 30, 40, 2, 0.1, 0.2, 0.3);
|
insertRollup.run(3_999, video1, 1, 10, 20, 40, 2, 0.1, 0.2, 0.3);
|
||||||
|
|
||||||
const rows = getAnimeDailyRollups(db, animeId, 1);
|
const rows = getAnimeDailyRollups(db, animeId, 1);
|
||||||
assert.equal(rows.length, 2);
|
assert.equal(rows.length, 2);
|
||||||
@@ -1112,6 +1160,78 @@ test('getSessionEvents respects limit parameter', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('getSessionEvents filters by event type before applying limit', () => {
|
||||||
|
const dbPath = makeDbPath();
|
||||||
|
const db = new Database(dbPath);
|
||||||
|
|
||||||
|
try {
|
||||||
|
ensureSchema(db);
|
||||||
|
const stmts = createTrackerPreparedStatements(db);
|
||||||
|
|
||||||
|
const videoId = getOrCreateVideoRecord(db, 'local:/tmp/events-type-filter.mkv', {
|
||||||
|
canonicalTitle: 'Events Type Filter',
|
||||||
|
sourcePath: '/tmp/events-type-filter.mkv',
|
||||||
|
sourceUrl: null,
|
||||||
|
sourceType: SOURCE_TYPE_LOCAL,
|
||||||
|
});
|
||||||
|
|
||||||
|
const startedAtMs = 7_500_000;
|
||||||
|
const { sessionId } = startSessionRecord(db, videoId, startedAtMs);
|
||||||
|
|
||||||
|
for (let i = 0; i < 5; i += 1) {
|
||||||
|
stmts.eventInsertStmt.run(
|
||||||
|
sessionId,
|
||||||
|
startedAtMs + i * 1_000,
|
||||||
|
EVENT_SUBTITLE_LINE,
|
||||||
|
i,
|
||||||
|
0,
|
||||||
|
500,
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
`{"line":"subtitle-${i}"}`,
|
||||||
|
startedAtMs + i * 1_000,
|
||||||
|
startedAtMs + i * 1_000,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
stmts.eventInsertStmt.run(
|
||||||
|
sessionId,
|
||||||
|
startedAtMs + 10_000,
|
||||||
|
EVENT_CARD_MINED,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
'{"cardsMined":1}',
|
||||||
|
startedAtMs + 10_000,
|
||||||
|
startedAtMs + 10_000,
|
||||||
|
);
|
||||||
|
|
||||||
|
stmts.eventInsertStmt.run(
|
||||||
|
sessionId,
|
||||||
|
startedAtMs + 11_000,
|
||||||
|
EVENT_YOMITAN_LOOKUP,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
null,
|
||||||
|
startedAtMs + 11_000,
|
||||||
|
startedAtMs + 11_000,
|
||||||
|
);
|
||||||
|
|
||||||
|
const filtered = getSessionEvents(db, sessionId, 1, [EVENT_CARD_MINED]);
|
||||||
|
assert.equal(filtered.length, 1);
|
||||||
|
assert.equal(filtered[0]?.eventType, EVENT_CARD_MINED);
|
||||||
|
assert.equal(filtered[0]?.payload, '{"cardsMined":1}');
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
cleanupDbPath(dbPath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('getSessionWordsByLine joins word occurrences through imm_words.id', () => {
|
test('getSessionWordsByLine joins word occurrences through imm_words.id', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = new Database(dbPath);
|
const db = new Database(dbPath);
|
||||||
@@ -1251,7 +1371,6 @@ test('anime-level queries group by anime_id and preserve episode-level rows', ()
|
|||||||
3_000,
|
3_000,
|
||||||
10,
|
10,
|
||||||
25,
|
25,
|
||||||
25,
|
|
||||||
1,
|
1,
|
||||||
3,
|
3,
|
||||||
2,
|
2,
|
||||||
@@ -1270,7 +1389,6 @@ test('anime-level queries group by anime_id and preserve episode-level rows', ()
|
|||||||
4_000,
|
4_000,
|
||||||
11,
|
11,
|
||||||
27,
|
27,
|
||||||
27,
|
|
||||||
2,
|
2,
|
||||||
4,
|
4,
|
||||||
2,
|
2,
|
||||||
@@ -1289,7 +1407,6 @@ test('anime-level queries group by anime_id and preserve episode-level rows', ()
|
|||||||
5_000,
|
5_000,
|
||||||
12,
|
12,
|
||||||
28,
|
28,
|
||||||
28,
|
|
||||||
3,
|
3,
|
||||||
5,
|
5,
|
||||||
4,
|
4,
|
||||||
@@ -1308,7 +1425,6 @@ test('anime-level queries group by anime_id and preserve episode-level rows', ()
|
|||||||
3_500,
|
3_500,
|
||||||
8,
|
8,
|
||||||
20,
|
20,
|
||||||
20,
|
|
||||||
1,
|
1,
|
||||||
2,
|
2,
|
||||||
1,
|
1,
|
||||||
@@ -1329,7 +1445,6 @@ test('anime-level queries group by anime_id and preserve episode-level rows', ()
|
|||||||
total_sessions,
|
total_sessions,
|
||||||
total_active_ms,
|
total_active_ms,
|
||||||
total_cards,
|
total_cards,
|
||||||
total_words_seen,
|
|
||||||
total_lines_seen,
|
total_lines_seen,
|
||||||
total_tokens_seen,
|
total_tokens_seen,
|
||||||
episodes_started,
|
episodes_started,
|
||||||
@@ -1338,9 +1453,9 @@ test('anime-level queries group by anime_id and preserve episode-level rows', ()
|
|||||||
last_watched_ms,
|
last_watched_ms,
|
||||||
CREATED_DATE,
|
CREATED_DATE,
|
||||||
LAST_UPDATE_DATE
|
LAST_UPDATE_DATE
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`,
|
`,
|
||||||
).run(lwaAnimeId, 3, 12_000, 6, 80, 33, 0, 2, 1, 1_000_000, 1_021_000, now, now);
|
).run(lwaAnimeId, 3, 12_000, 6, 33, 80, 2, 1, 1_000_000, 1_021_000, now, now);
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`
|
`
|
||||||
INSERT INTO imm_lifetime_anime (
|
INSERT INTO imm_lifetime_anime (
|
||||||
@@ -1348,7 +1463,6 @@ test('anime-level queries group by anime_id and preserve episode-level rows', ()
|
|||||||
total_sessions,
|
total_sessions,
|
||||||
total_active_ms,
|
total_active_ms,
|
||||||
total_cards,
|
total_cards,
|
||||||
total_words_seen,
|
|
||||||
total_lines_seen,
|
total_lines_seen,
|
||||||
total_tokens_seen,
|
total_tokens_seen,
|
||||||
episodes_started,
|
episodes_started,
|
||||||
@@ -1357,9 +1471,9 @@ test('anime-level queries group by anime_id and preserve episode-level rows', ()
|
|||||||
last_watched_ms,
|
last_watched_ms,
|
||||||
CREATED_DATE,
|
CREATED_DATE,
|
||||||
LAST_UPDATE_DATE
|
LAST_UPDATE_DATE
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`,
|
`,
|
||||||
).run(frierenAnimeId, 1, 3_500, 1, 20, 8, 0, 1, 1, 1_030_000, 1_030_000, now, now);
|
).run(frierenAnimeId, 1, 3_500, 1, 8, 20, 1, 1, 1_030_000, 1_030_000, now, now);
|
||||||
|
|
||||||
const animeLibrary = getAnimeLibrary(db);
|
const animeLibrary = getAnimeLibrary(db);
|
||||||
assert.equal(animeLibrary.length, 2);
|
assert.equal(animeLibrary.length, 2);
|
||||||
@@ -1400,7 +1514,7 @@ test('anime-level queries group by anime_id and preserve episode-level rows', ()
|
|||||||
assert.equal(animeDetail?.totalSessions, 3);
|
assert.equal(animeDetail?.totalSessions, 3);
|
||||||
assert.equal(animeDetail?.totalActiveMs, 12_000);
|
assert.equal(animeDetail?.totalActiveMs, 12_000);
|
||||||
assert.equal(animeDetail?.totalCards, 6);
|
assert.equal(animeDetail?.totalCards, 6);
|
||||||
assert.equal(animeDetail?.totalWordsSeen, 80);
|
assert.equal(animeDetail?.totalTokensSeen, 80);
|
||||||
assert.equal(animeDetail?.totalLinesSeen, 33);
|
assert.equal(animeDetail?.totalLinesSeen, 33);
|
||||||
assert.equal(animeDetail?.totalLookupCount, 12);
|
assert.equal(animeDetail?.totalLookupCount, 12);
|
||||||
assert.equal(animeDetail?.totalLookupHits, 8);
|
assert.equal(animeDetail?.totalLookupHits, 8);
|
||||||
@@ -1416,7 +1530,7 @@ test('anime-level queries group by anime_id and preserve episode-level rows', ()
|
|||||||
totalSessions: row.totalSessions,
|
totalSessions: row.totalSessions,
|
||||||
totalActiveMs: row.totalActiveMs,
|
totalActiveMs: row.totalActiveMs,
|
||||||
totalCards: row.totalCards,
|
totalCards: row.totalCards,
|
||||||
totalWordsSeen: row.totalWordsSeen,
|
totalTokensSeen: row.totalTokensSeen,
|
||||||
totalYomitanLookupCount: row.totalYomitanLookupCount,
|
totalYomitanLookupCount: row.totalYomitanLookupCount,
|
||||||
})),
|
})),
|
||||||
[
|
[
|
||||||
@@ -1427,7 +1541,7 @@ test('anime-level queries group by anime_id and preserve episode-level rows', ()
|
|||||||
totalSessions: 2,
|
totalSessions: 2,
|
||||||
totalActiveMs: 7_000,
|
totalActiveMs: 7_000,
|
||||||
totalCards: 3,
|
totalCards: 3,
|
||||||
totalWordsSeen: 52,
|
totalTokensSeen: 52,
|
||||||
totalYomitanLookupCount: 0,
|
totalYomitanLookupCount: 0,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1437,7 +1551,7 @@ test('anime-level queries group by anime_id and preserve episode-level rows', ()
|
|||||||
totalSessions: 1,
|
totalSessions: 1,
|
||||||
totalActiveMs: 5_000,
|
totalActiveMs: 5_000,
|
||||||
totalCards: 3,
|
totalCards: 3,
|
||||||
totalWordsSeen: 28,
|
totalTokensSeen: 28,
|
||||||
totalYomitanLookupCount: 0,
|
totalYomitanLookupCount: 0,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -1506,7 +1620,6 @@ test('anime library and detail still return lifetime rows without retained sessi
|
|||||||
total_sessions,
|
total_sessions,
|
||||||
total_active_ms,
|
total_active_ms,
|
||||||
total_cards,
|
total_cards,
|
||||||
total_words_seen,
|
|
||||||
total_lines_seen,
|
total_lines_seen,
|
||||||
total_tokens_seen,
|
total_tokens_seen,
|
||||||
episodes_started,
|
episodes_started,
|
||||||
@@ -1515,9 +1628,9 @@ test('anime library and detail still return lifetime rows without retained sessi
|
|||||||
last_watched_ms,
|
last_watched_ms,
|
||||||
CREATED_DATE,
|
CREATED_DATE,
|
||||||
LAST_UPDATE_DATE
|
LAST_UPDATE_DATE
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`,
|
`,
|
||||||
).run(animeId, 12, 4_500, 9, 200, 80, 15, 2, 2, 1_000_000, now, now, now);
|
).run(animeId, 12, 4_500, 9, 80, 200, 2, 2, 1_000_000, now, now, now);
|
||||||
|
|
||||||
const library = getAnimeLibrary(db);
|
const library = getAnimeLibrary(db);
|
||||||
assert.equal(library.length, 1);
|
assert.equal(library.length, 1);
|
||||||
@@ -1535,7 +1648,7 @@ test('anime library and detail still return lifetime rows without retained sessi
|
|||||||
assert.equal(detail?.totalSessions, 12);
|
assert.equal(detail?.totalSessions, 12);
|
||||||
assert.equal(detail?.totalActiveMs, 4_500);
|
assert.equal(detail?.totalActiveMs, 4_500);
|
||||||
assert.equal(detail?.totalCards, 9);
|
assert.equal(detail?.totalCards, 9);
|
||||||
assert.equal(detail?.totalWordsSeen, 200);
|
assert.equal(detail?.totalTokensSeen, 200);
|
||||||
assert.equal(detail?.totalLinesSeen, 80);
|
assert.equal(detail?.totalLinesSeen, 80);
|
||||||
assert.equal(detail?.episodeCount, 2);
|
assert.equal(detail?.episodeCount, 2);
|
||||||
assert.equal(detail?.totalLookupCount, 0);
|
assert.equal(detail?.totalLookupCount, 0);
|
||||||
@@ -1573,7 +1686,6 @@ test('media library and detail queries read lifetime totals', () => {
|
|||||||
total_sessions,
|
total_sessions,
|
||||||
total_active_ms,
|
total_active_ms,
|
||||||
total_cards,
|
total_cards,
|
||||||
total_words_seen,
|
|
||||||
total_lines_seen,
|
total_lines_seen,
|
||||||
total_tokens_seen,
|
total_tokens_seen,
|
||||||
completed,
|
completed,
|
||||||
@@ -1581,13 +1693,13 @@ test('media library and detail queries read lifetime totals', () => {
|
|||||||
last_watched_ms,
|
last_watched_ms,
|
||||||
CREATED_DATE,
|
CREATED_DATE,
|
||||||
LAST_UPDATE_DATE
|
LAST_UPDATE_DATE
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`,
|
`,
|
||||||
);
|
);
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const older = now - 10_000;
|
const older = now - 10_000;
|
||||||
insertLifetime.run(mediaOne, 3, 12_000, 4, 180, 10, 20, 1, 1_000, now, now, now, now);
|
insertLifetime.run(mediaOne, 3, 12_000, 4, 10, 180, 1, 1_000, now, now, now);
|
||||||
insertLifetime.run(mediaTwo, 1, 2_000, 2, 40, 4, 6, 0, 900, older, now, now);
|
insertLifetime.run(mediaTwo, 1, 2_000, 2, 4, 40, 0, 900, older, now, now);
|
||||||
|
|
||||||
const library = getMediaLibrary(db);
|
const library = getMediaLibrary(db);
|
||||||
assert.equal(library.length, 2);
|
assert.equal(library.length, 2);
|
||||||
@@ -1598,7 +1710,7 @@ test('media library and detail queries read lifetime totals', () => {
|
|||||||
totalSessions: row.totalSessions,
|
totalSessions: row.totalSessions,
|
||||||
totalActiveMs: row.totalActiveMs,
|
totalActiveMs: row.totalActiveMs,
|
||||||
totalCards: row.totalCards,
|
totalCards: row.totalCards,
|
||||||
totalWordsSeen: row.totalWordsSeen,
|
totalTokensSeen: row.totalTokensSeen,
|
||||||
lastWatchedMs: row.lastWatchedMs,
|
lastWatchedMs: row.lastWatchedMs,
|
||||||
hasCoverArt: row.hasCoverArt,
|
hasCoverArt: row.hasCoverArt,
|
||||||
})),
|
})),
|
||||||
@@ -1609,7 +1721,7 @@ test('media library and detail queries read lifetime totals', () => {
|
|||||||
totalSessions: 3,
|
totalSessions: 3,
|
||||||
totalActiveMs: 12_000,
|
totalActiveMs: 12_000,
|
||||||
totalCards: 4,
|
totalCards: 4,
|
||||||
totalWordsSeen: 180,
|
totalTokensSeen: 180,
|
||||||
lastWatchedMs: now,
|
lastWatchedMs: now,
|
||||||
hasCoverArt: 0,
|
hasCoverArt: 0,
|
||||||
},
|
},
|
||||||
@@ -1619,7 +1731,7 @@ test('media library and detail queries read lifetime totals', () => {
|
|||||||
totalSessions: 1,
|
totalSessions: 1,
|
||||||
totalActiveMs: 2_000,
|
totalActiveMs: 2_000,
|
||||||
totalCards: 2,
|
totalCards: 2,
|
||||||
totalWordsSeen: 40,
|
totalTokensSeen: 40,
|
||||||
lastWatchedMs: older,
|
lastWatchedMs: older,
|
||||||
hasCoverArt: 0,
|
hasCoverArt: 0,
|
||||||
},
|
},
|
||||||
@@ -1631,7 +1743,7 @@ test('media library and detail queries read lifetime totals', () => {
|
|||||||
assert.equal(detail.totalSessions, 3);
|
assert.equal(detail.totalSessions, 3);
|
||||||
assert.equal(detail.totalActiveMs, 12_000);
|
assert.equal(detail.totalActiveMs, 12_000);
|
||||||
assert.equal(detail.totalCards, 4);
|
assert.equal(detail.totalCards, 4);
|
||||||
assert.equal(detail.totalWordsSeen, 180);
|
assert.equal(detail.totalTokensSeen, 180);
|
||||||
assert.equal(detail.totalLinesSeen, 10);
|
assert.equal(detail.totalLinesSeen, 10);
|
||||||
} finally {
|
} finally {
|
||||||
db.close();
|
db.close();
|
||||||
@@ -1697,7 +1809,6 @@ test('cover art queries reuse a shared blob across duplicate anime art rows', ()
|
|||||||
total_sessions,
|
total_sessions,
|
||||||
total_active_ms,
|
total_active_ms,
|
||||||
total_cards,
|
total_cards,
|
||||||
total_words_seen,
|
|
||||||
total_lines_seen,
|
total_lines_seen,
|
||||||
total_tokens_seen,
|
total_tokens_seen,
|
||||||
completed,
|
completed,
|
||||||
@@ -1705,7 +1816,7 @@ test('cover art queries reuse a shared blob across duplicate anime art rows', ()
|
|||||||
last_watched_ms,
|
last_watched_ms,
|
||||||
CREATED_DATE,
|
CREATED_DATE,
|
||||||
LAST_UPDATE_DATE
|
LAST_UPDATE_DATE
|
||||||
) VALUES (?, 1, 1000, 0, 0, 0, 0, 0, ?, ?, ?, ?)
|
) VALUES (?, 1, 1000, 0, 0, 0, 0, ?, ?, ?, ?)
|
||||||
`,
|
`,
|
||||||
).run(videoOne, now, now, now, now);
|
).run(videoOne, now, now, now, now);
|
||||||
db.prepare(
|
db.prepare(
|
||||||
@@ -1715,7 +1826,6 @@ test('cover art queries reuse a shared blob across duplicate anime art rows', ()
|
|||||||
total_sessions,
|
total_sessions,
|
||||||
total_active_ms,
|
total_active_ms,
|
||||||
total_cards,
|
total_cards,
|
||||||
total_words_seen,
|
|
||||||
total_lines_seen,
|
total_lines_seen,
|
||||||
total_tokens_seen,
|
total_tokens_seen,
|
||||||
completed,
|
completed,
|
||||||
@@ -1723,7 +1833,7 @@ test('cover art queries reuse a shared blob across duplicate anime art rows', ()
|
|||||||
last_watched_ms,
|
last_watched_ms,
|
||||||
CREATED_DATE,
|
CREATED_DATE,
|
||||||
LAST_UPDATE_DATE
|
LAST_UPDATE_DATE
|
||||||
) VALUES (?, 1, 1000, 0, 0, 0, 0, 0, ?, ?, ?, ?)
|
) VALUES (?, 1, 1000, 0, 0, 0, 0, ?, ?, ?, ?)
|
||||||
`,
|
`,
|
||||||
).run(videoTwo, now, now - 1, now, now);
|
).run(videoTwo, now, now - 1, now, now);
|
||||||
|
|
||||||
@@ -1823,20 +1933,20 @@ test('anime/media detail and episode queries use ended-session metrics when tele
|
|||||||
db.prepare(
|
db.prepare(
|
||||||
`
|
`
|
||||||
INSERT INTO imm_lifetime_anime (
|
INSERT INTO imm_lifetime_anime (
|
||||||
anime_id, total_sessions, total_active_ms, total_cards, total_words_seen, total_lines_seen,
|
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,
|
total_tokens_seen, episodes_started, episodes_completed, first_watched_ms, last_watched_ms,
|
||||||
CREATED_DATE, LAST_UPDATE_DATE
|
CREATED_DATE, LAST_UPDATE_DATE
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`,
|
`,
|
||||||
).run(animeId, 3, 12_000, 6, 60, 24, 60, 2, 2, 1_000_000, 1_020_000, now, now);
|
).run(animeId, 3, 12_000, 6, 24, 60, 2, 2, 1_000_000, 1_020_000, now, now);
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`
|
`
|
||||||
INSERT INTO imm_lifetime_media (
|
INSERT INTO imm_lifetime_media (
|
||||||
video_id, total_sessions, total_active_ms, total_cards, total_words_seen, total_lines_seen,
|
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
|
total_tokens_seen, completed, first_watched_ms, last_watched_ms, CREATED_DATE, LAST_UPDATE_DATE
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`,
|
`,
|
||||||
).run(episodeOne, 2, 7_000, 3, 30, 12, 30, 1, 1_000_000, 1_010_000, now, now);
|
).run(episodeOne, 2, 7_000, 3, 12, 30, 1, 1_000_000, 1_010_000, now, now);
|
||||||
|
|
||||||
const s1 = startSessionRecord(db, episodeOne, 1_000_000).sessionId;
|
const s1 = startSessionRecord(db, episodeOne, 1_000_000).sessionId;
|
||||||
const s2 = startSessionRecord(db, episodeOne, 1_010_000).sessionId;
|
const s2 = startSessionRecord(db, episodeOne, 1_010_000).sessionId;
|
||||||
@@ -1849,7 +1959,7 @@ test('anime/media detail and episode queries use ended-session metrics when tele
|
|||||||
status = 2,
|
status = 2,
|
||||||
active_watched_ms = ?,
|
active_watched_ms = ?,
|
||||||
cards_mined = ?,
|
cards_mined = ?,
|
||||||
words_seen = ?,
|
tokens_seen = ?,
|
||||||
lookup_count = ?,
|
lookup_count = ?,
|
||||||
lookup_hits = ?,
|
lookup_hits = ?,
|
||||||
LAST_UPDATE_DATE = ?
|
LAST_UPDATE_DATE = ?
|
||||||
@@ -1872,7 +1982,7 @@ test('anime/media detail and episode queries use ended-session metrics when tele
|
|||||||
totalSessions: row.totalSessions,
|
totalSessions: row.totalSessions,
|
||||||
totalActiveMs: row.totalActiveMs,
|
totalActiveMs: row.totalActiveMs,
|
||||||
totalCards: row.totalCards,
|
totalCards: row.totalCards,
|
||||||
totalWordsSeen: row.totalWordsSeen,
|
totalTokensSeen: row.totalTokensSeen,
|
||||||
})),
|
})),
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
@@ -1880,14 +1990,14 @@ test('anime/media detail and episode queries use ended-session metrics when tele
|
|||||||
totalSessions: 2,
|
totalSessions: 2,
|
||||||
totalActiveMs: 7_000,
|
totalActiveMs: 7_000,
|
||||||
totalCards: 3,
|
totalCards: 3,
|
||||||
totalWordsSeen: 30,
|
totalTokensSeen: 30,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
videoId: episodeTwo,
|
videoId: episodeTwo,
|
||||||
totalSessions: 1,
|
totalSessions: 1,
|
||||||
totalActiveMs: 5_000,
|
totalActiveMs: 5_000,
|
||||||
totalCards: 3,
|
totalCards: 3,
|
||||||
totalWordsSeen: 30,
|
totalTokensSeen: 30,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -1897,7 +2007,7 @@ test('anime/media detail and episode queries use ended-session metrics when tele
|
|||||||
assert.equal(mediaDetail?.totalSessions, 2);
|
assert.equal(mediaDetail?.totalSessions, 2);
|
||||||
assert.equal(mediaDetail?.totalActiveMs, 7_000);
|
assert.equal(mediaDetail?.totalActiveMs, 7_000);
|
||||||
assert.equal(mediaDetail?.totalCards, 3);
|
assert.equal(mediaDetail?.totalCards, 3);
|
||||||
assert.equal(mediaDetail?.totalWordsSeen, 30);
|
assert.equal(mediaDetail?.totalTokensSeen, 30);
|
||||||
assert.equal(mediaDetail?.totalLookupCount, 9);
|
assert.equal(mediaDetail?.totalLookupCount, 9);
|
||||||
assert.equal(mediaDetail?.totalLookupHits, 7);
|
assert.equal(mediaDetail?.totalLookupHits, 7);
|
||||||
assert.equal(mediaDetail?.totalYomitanLookupCount, 0);
|
assert.equal(mediaDetail?.totalYomitanLookupCount, 0);
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ interface TelemetryRow {
|
|||||||
cards_mined: number | null;
|
cards_mined: number | null;
|
||||||
lines_seen: number | null;
|
lines_seen: number | null;
|
||||||
tokens_seen: number | null;
|
tokens_seen: number | null;
|
||||||
words_seen: number | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface VideoRow {
|
interface VideoRow {
|
||||||
@@ -46,7 +45,6 @@ interface RetainedSessionRow {
|
|||||||
totalWatchedMs: number;
|
totalWatchedMs: number;
|
||||||
activeWatchedMs: number;
|
activeWatchedMs: number;
|
||||||
linesSeen: number;
|
linesSeen: number;
|
||||||
wordsSeen: number;
|
|
||||||
tokensSeen: number;
|
tokensSeen: number;
|
||||||
cardsMined: number;
|
cardsMined: number;
|
||||||
lookupCount: number;
|
lookupCount: number;
|
||||||
@@ -150,7 +148,6 @@ function toRebuildSessionState(row: RetainedSessionRow): SessionState {
|
|||||||
totalWatchedMs: Math.max(0, row.totalWatchedMs),
|
totalWatchedMs: Math.max(0, row.totalWatchedMs),
|
||||||
activeWatchedMs: Math.max(0, row.activeWatchedMs),
|
activeWatchedMs: Math.max(0, row.activeWatchedMs),
|
||||||
linesSeen: Math.max(0, row.linesSeen),
|
linesSeen: Math.max(0, row.linesSeen),
|
||||||
wordsSeen: Math.max(0, row.wordsSeen),
|
|
||||||
tokensSeen: Math.max(0, row.tokensSeen),
|
tokensSeen: Math.max(0, row.tokensSeen),
|
||||||
cardsMined: Math.max(0, row.cardsMined),
|
cardsMined: Math.max(0, row.cardsMined),
|
||||||
lookupCount: Math.max(0, row.lookupCount),
|
lookupCount: Math.max(0, row.lookupCount),
|
||||||
@@ -176,7 +173,6 @@ function getRetainedStaleActiveSessions(db: DatabaseSync): RetainedSessionRow[]
|
|||||||
COALESCE(t.total_watched_ms, s.total_watched_ms, 0) AS totalWatchedMs,
|
COALESCE(t.total_watched_ms, s.total_watched_ms, 0) AS totalWatchedMs,
|
||||||
COALESCE(t.active_watched_ms, s.active_watched_ms, 0) AS activeWatchedMs,
|
COALESCE(t.active_watched_ms, s.active_watched_ms, 0) AS activeWatchedMs,
|
||||||
COALESCE(t.lines_seen, s.lines_seen, 0) AS linesSeen,
|
COALESCE(t.lines_seen, s.lines_seen, 0) AS linesSeen,
|
||||||
COALESCE(t.words_seen, s.words_seen, 0) AS wordsSeen,
|
|
||||||
COALESCE(t.tokens_seen, s.tokens_seen, 0) AS tokensSeen,
|
COALESCE(t.tokens_seen, s.tokens_seen, 0) AS tokensSeen,
|
||||||
COALESCE(t.cards_mined, s.cards_mined, 0) AS cardsMined,
|
COALESCE(t.cards_mined, s.cards_mined, 0) AS cardsMined,
|
||||||
COALESCE(t.lookup_count, s.lookup_count, 0) AS lookupCount,
|
COALESCE(t.lookup_count, s.lookup_count, 0) AS lookupCount,
|
||||||
@@ -209,7 +205,6 @@ function upsertLifetimeMedia(
|
|||||||
nowMs: number,
|
nowMs: number,
|
||||||
activeMs: number,
|
activeMs: number,
|
||||||
cardsMined: number,
|
cardsMined: number,
|
||||||
wordsSeen: number,
|
|
||||||
linesSeen: number,
|
linesSeen: number,
|
||||||
tokensSeen: number,
|
tokensSeen: number,
|
||||||
completed: number,
|
completed: number,
|
||||||
@@ -223,7 +218,6 @@ function upsertLifetimeMedia(
|
|||||||
total_sessions,
|
total_sessions,
|
||||||
total_active_ms,
|
total_active_ms,
|
||||||
total_cards,
|
total_cards,
|
||||||
total_words_seen,
|
|
||||||
total_lines_seen,
|
total_lines_seen,
|
||||||
total_tokens_seen,
|
total_tokens_seen,
|
||||||
completed,
|
completed,
|
||||||
@@ -232,12 +226,11 @@ function upsertLifetimeMedia(
|
|||||||
CREATED_DATE,
|
CREATED_DATE,
|
||||||
LAST_UPDATE_DATE
|
LAST_UPDATE_DATE
|
||||||
)
|
)
|
||||||
VALUES (?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
ON CONFLICT(video_id) DO UPDATE SET
|
ON CONFLICT(video_id) DO UPDATE SET
|
||||||
total_sessions = total_sessions + 1,
|
total_sessions = total_sessions + 1,
|
||||||
total_active_ms = total_active_ms + excluded.total_active_ms,
|
total_active_ms = total_active_ms + excluded.total_active_ms,
|
||||||
total_cards = total_cards + excluded.total_cards,
|
total_cards = total_cards + excluded.total_cards,
|
||||||
total_words_seen = total_words_seen + excluded.total_words_seen,
|
|
||||||
total_lines_seen = total_lines_seen + excluded.total_lines_seen,
|
total_lines_seen = total_lines_seen + excluded.total_lines_seen,
|
||||||
total_tokens_seen = total_tokens_seen + excluded.total_tokens_seen,
|
total_tokens_seen = total_tokens_seen + excluded.total_tokens_seen,
|
||||||
completed = MAX(completed, excluded.completed),
|
completed = MAX(completed, excluded.completed),
|
||||||
@@ -259,7 +252,6 @@ function upsertLifetimeMedia(
|
|||||||
videoId,
|
videoId,
|
||||||
activeMs,
|
activeMs,
|
||||||
cardsMined,
|
cardsMined,
|
||||||
wordsSeen,
|
|
||||||
linesSeen,
|
linesSeen,
|
||||||
tokensSeen,
|
tokensSeen,
|
||||||
completed,
|
completed,
|
||||||
@@ -276,7 +268,6 @@ function upsertLifetimeAnime(
|
|||||||
nowMs: number,
|
nowMs: number,
|
||||||
activeMs: number,
|
activeMs: number,
|
||||||
cardsMined: number,
|
cardsMined: number,
|
||||||
wordsSeen: number,
|
|
||||||
linesSeen: number,
|
linesSeen: number,
|
||||||
tokensSeen: number,
|
tokensSeen: number,
|
||||||
episodesStartedDelta: number,
|
episodesStartedDelta: number,
|
||||||
@@ -291,7 +282,6 @@ function upsertLifetimeAnime(
|
|||||||
total_sessions,
|
total_sessions,
|
||||||
total_active_ms,
|
total_active_ms,
|
||||||
total_cards,
|
total_cards,
|
||||||
total_words_seen,
|
|
||||||
total_lines_seen,
|
total_lines_seen,
|
||||||
total_tokens_seen,
|
total_tokens_seen,
|
||||||
episodes_started,
|
episodes_started,
|
||||||
@@ -301,12 +291,11 @@ function upsertLifetimeAnime(
|
|||||||
CREATED_DATE,
|
CREATED_DATE,
|
||||||
LAST_UPDATE_DATE
|
LAST_UPDATE_DATE
|
||||||
)
|
)
|
||||||
VALUES (?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
ON CONFLICT(anime_id) DO UPDATE SET
|
ON CONFLICT(anime_id) DO UPDATE SET
|
||||||
total_sessions = total_sessions + 1,
|
total_sessions = total_sessions + 1,
|
||||||
total_active_ms = total_active_ms + excluded.total_active_ms,
|
total_active_ms = total_active_ms + excluded.total_active_ms,
|
||||||
total_cards = total_cards + excluded.total_cards,
|
total_cards = total_cards + excluded.total_cards,
|
||||||
total_words_seen = total_words_seen + excluded.total_words_seen,
|
|
||||||
total_lines_seen = total_lines_seen + excluded.total_lines_seen,
|
total_lines_seen = total_lines_seen + excluded.total_lines_seen,
|
||||||
total_tokens_seen = total_tokens_seen + excluded.total_tokens_seen,
|
total_tokens_seen = total_tokens_seen + excluded.total_tokens_seen,
|
||||||
episodes_started = episodes_started + excluded.episodes_started,
|
episodes_started = episodes_started + excluded.episodes_started,
|
||||||
@@ -329,7 +318,6 @@ function upsertLifetimeAnime(
|
|||||||
animeId,
|
animeId,
|
||||||
activeMs,
|
activeMs,
|
||||||
cardsMined,
|
cardsMined,
|
||||||
wordsSeen,
|
|
||||||
linesSeen,
|
linesSeen,
|
||||||
tokensSeen,
|
tokensSeen,
|
||||||
episodesStartedDelta,
|
episodesStartedDelta,
|
||||||
@@ -372,7 +360,6 @@ export function applySessionLifetimeSummary(
|
|||||||
SELECT
|
SELECT
|
||||||
active_watched_ms,
|
active_watched_ms,
|
||||||
cards_mined,
|
cards_mined,
|
||||||
words_seen,
|
|
||||||
lines_seen,
|
lines_seen,
|
||||||
tokens_seen
|
tokens_seen
|
||||||
FROM imm_session_telemetry
|
FROM imm_session_telemetry
|
||||||
@@ -407,9 +394,6 @@ export function applySessionLifetimeSummary(
|
|||||||
const cardsMined = telemetry
|
const cardsMined = telemetry
|
||||||
? asPositiveNumber(telemetry.cards_mined, session.cardsMined)
|
? asPositiveNumber(telemetry.cards_mined, session.cardsMined)
|
||||||
: session.cardsMined;
|
: session.cardsMined;
|
||||||
const wordsSeen = telemetry
|
|
||||||
? asPositiveNumber(telemetry.words_seen, session.wordsSeen)
|
|
||||||
: session.wordsSeen;
|
|
||||||
const linesSeen = telemetry
|
const linesSeen = telemetry
|
||||||
? asPositiveNumber(telemetry.lines_seen, session.linesSeen)
|
? asPositiveNumber(telemetry.lines_seen, session.linesSeen)
|
||||||
: session.linesSeen;
|
: session.linesSeen;
|
||||||
@@ -470,7 +454,6 @@ export function applySessionLifetimeSummary(
|
|||||||
nowMs,
|
nowMs,
|
||||||
activeMs,
|
activeMs,
|
||||||
cardsMined,
|
cardsMined,
|
||||||
wordsSeen,
|
|
||||||
linesSeen,
|
linesSeen,
|
||||||
tokensSeen,
|
tokensSeen,
|
||||||
watched > 0 ? 1 : 0,
|
watched > 0 ? 1 : 0,
|
||||||
@@ -485,7 +468,6 @@ export function applySessionLifetimeSummary(
|
|||||||
nowMs,
|
nowMs,
|
||||||
activeMs,
|
activeMs,
|
||||||
cardsMined,
|
cardsMined,
|
||||||
wordsSeen,
|
|
||||||
linesSeen,
|
linesSeen,
|
||||||
tokensSeen,
|
tokensSeen,
|
||||||
isFirstSessionForVideoRun ? 1 : 0,
|
isFirstSessionForVideoRun ? 1 : 0,
|
||||||
@@ -509,7 +491,6 @@ export function rebuildLifetimeSummaries(db: DatabaseSync): LifetimeRebuildSumma
|
|||||||
total_watched_ms AS totalWatchedMs,
|
total_watched_ms AS totalWatchedMs,
|
||||||
active_watched_ms AS activeWatchedMs,
|
active_watched_ms AS activeWatchedMs,
|
||||||
lines_seen AS linesSeen,
|
lines_seen AS linesSeen,
|
||||||
words_seen AS wordsSeen,
|
|
||||||
tokens_seen AS tokensSeen,
|
tokens_seen AS tokensSeen,
|
||||||
cards_mined AS cardsMined,
|
cards_mined AS cardsMined,
|
||||||
lookup_count AS lookupCount,
|
lookup_count AS lookupCount,
|
||||||
|
|||||||
@@ -109,16 +109,16 @@ test('raw retention keeps rollups and rollup retention prunes them separately',
|
|||||||
1, ${nowMs - 90 * 86_400_000}, 0, 0, ${nowMs}, ${nowMs}
|
1, ${nowMs - 90 * 86_400_000}, 0, 0, ${nowMs}, ${nowMs}
|
||||||
);
|
);
|
||||||
INSERT INTO imm_daily_rollups (
|
INSERT INTO imm_daily_rollups (
|
||||||
rollup_day, video_id, total_sessions, total_active_min, total_lines_seen, total_words_seen,
|
rollup_day, video_id, total_sessions, total_active_min, total_lines_seen,
|
||||||
total_tokens_seen, total_cards
|
total_tokens_seen, total_cards
|
||||||
) VALUES (
|
) VALUES (
|
||||||
${oldDay}, 1, 1, 10, 1, 1, 1, 1
|
${oldDay}, 1, 1, 10, 1, 1, 1
|
||||||
);
|
);
|
||||||
INSERT INTO imm_monthly_rollups (
|
INSERT INTO imm_monthly_rollups (
|
||||||
rollup_month, video_id, total_sessions, total_active_min, total_lines_seen, total_words_seen,
|
rollup_month, video_id, total_sessions, total_active_min, total_lines_seen,
|
||||||
total_tokens_seen, total_cards, CREATED_DATE, LAST_UPDATE_DATE
|
total_tokens_seen, total_cards, CREATED_DATE, LAST_UPDATE_DATE
|
||||||
) VALUES (
|
) VALUES (
|
||||||
${oldMonth}, 1, 1, 10, 1, 1, 1, 1, ${nowMs}, ${nowMs}
|
${oldMonth}, 1, 1, 10, 1, 1, 1, ${nowMs}, ${nowMs}
|
||||||
);
|
);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
|||||||
@@ -125,8 +125,8 @@ function upsertDailyRollupsForGroups(
|
|||||||
const upsertStmt = db.prepare(`
|
const upsertStmt = db.prepare(`
|
||||||
INSERT INTO imm_daily_rollups (
|
INSERT INTO imm_daily_rollups (
|
||||||
rollup_day, video_id, total_sessions, total_active_min, total_lines_seen,
|
rollup_day, video_id, total_sessions, total_active_min, total_lines_seen,
|
||||||
total_words_seen, total_tokens_seen, total_cards, cards_per_hour,
|
total_tokens_seen, total_cards, cards_per_hour,
|
||||||
words_per_min, lookup_hit_rate, CREATED_DATE, LAST_UPDATE_DATE
|
tokens_per_min, lookup_hit_rate, CREATED_DATE, LAST_UPDATE_DATE
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
CAST(julianday(s.started_at_ms / 1000, 'unixepoch', 'localtime') - 2440587.5 AS INTEGER) AS rollup_day,
|
CAST(julianday(s.started_at_ms / 1000, 'unixepoch', 'localtime') - 2440587.5 AS INTEGER) AS rollup_day,
|
||||||
@@ -134,7 +134,6 @@ function upsertDailyRollupsForGroups(
|
|||||||
COUNT(DISTINCT s.session_id) AS total_sessions,
|
COUNT(DISTINCT s.session_id) AS total_sessions,
|
||||||
COALESCE(SUM(sm.max_active_ms), 0) / 60000.0 AS total_active_min,
|
COALESCE(SUM(sm.max_active_ms), 0) / 60000.0 AS total_active_min,
|
||||||
COALESCE(SUM(sm.max_lines), 0) AS total_lines_seen,
|
COALESCE(SUM(sm.max_lines), 0) AS total_lines_seen,
|
||||||
COALESCE(SUM(sm.max_words), 0) AS total_words_seen,
|
|
||||||
COALESCE(SUM(sm.max_tokens), 0) AS total_tokens_seen,
|
COALESCE(SUM(sm.max_tokens), 0) AS total_tokens_seen,
|
||||||
COALESCE(SUM(sm.max_cards), 0) AS total_cards,
|
COALESCE(SUM(sm.max_cards), 0) AS total_cards,
|
||||||
CASE
|
CASE
|
||||||
@@ -144,9 +143,9 @@ function upsertDailyRollupsForGroups(
|
|||||||
END AS cards_per_hour,
|
END AS cards_per_hour,
|
||||||
CASE
|
CASE
|
||||||
WHEN COALESCE(SUM(sm.max_active_ms), 0) > 0
|
WHEN COALESCE(SUM(sm.max_active_ms), 0) > 0
|
||||||
THEN COALESCE(SUM(sm.max_words), 0) / (COALESCE(SUM(sm.max_active_ms), 0) / 60000.0)
|
THEN COALESCE(SUM(sm.max_tokens), 0) / (COALESCE(SUM(sm.max_active_ms), 0) / 60000.0)
|
||||||
ELSE NULL
|
ELSE NULL
|
||||||
END AS words_per_min,
|
END AS tokens_per_min,
|
||||||
CASE
|
CASE
|
||||||
WHEN COALESCE(SUM(sm.max_lookups), 0) > 0
|
WHEN COALESCE(SUM(sm.max_lookups), 0) > 0
|
||||||
THEN CAST(COALESCE(SUM(sm.max_hits), 0) AS REAL) / CAST(SUM(sm.max_lookups) AS REAL)
|
THEN CAST(COALESCE(SUM(sm.max_hits), 0) AS REAL) / CAST(SUM(sm.max_lookups) AS REAL)
|
||||||
@@ -160,7 +159,6 @@ function upsertDailyRollupsForGroups(
|
|||||||
t.session_id,
|
t.session_id,
|
||||||
MAX(t.active_watched_ms) AS max_active_ms,
|
MAX(t.active_watched_ms) AS max_active_ms,
|
||||||
MAX(t.lines_seen) AS max_lines,
|
MAX(t.lines_seen) AS max_lines,
|
||||||
MAX(t.words_seen) AS max_words,
|
|
||||||
MAX(t.tokens_seen) AS max_tokens,
|
MAX(t.tokens_seen) AS max_tokens,
|
||||||
MAX(t.cards_mined) AS max_cards,
|
MAX(t.cards_mined) AS max_cards,
|
||||||
MAX(t.lookup_count) AS max_lookups,
|
MAX(t.lookup_count) AS max_lookups,
|
||||||
@@ -174,11 +172,10 @@ function upsertDailyRollupsForGroups(
|
|||||||
total_sessions = excluded.total_sessions,
|
total_sessions = excluded.total_sessions,
|
||||||
total_active_min = excluded.total_active_min,
|
total_active_min = excluded.total_active_min,
|
||||||
total_lines_seen = excluded.total_lines_seen,
|
total_lines_seen = excluded.total_lines_seen,
|
||||||
total_words_seen = excluded.total_words_seen,
|
|
||||||
total_tokens_seen = excluded.total_tokens_seen,
|
total_tokens_seen = excluded.total_tokens_seen,
|
||||||
total_cards = excluded.total_cards,
|
total_cards = excluded.total_cards,
|
||||||
cards_per_hour = excluded.cards_per_hour,
|
cards_per_hour = excluded.cards_per_hour,
|
||||||
words_per_min = excluded.words_per_min,
|
tokens_per_min = excluded.tokens_per_min,
|
||||||
lookup_hit_rate = excluded.lookup_hit_rate,
|
lookup_hit_rate = excluded.lookup_hit_rate,
|
||||||
CREATED_DATE = COALESCE(imm_daily_rollups.CREATED_DATE, excluded.CREATED_DATE),
|
CREATED_DATE = COALESCE(imm_daily_rollups.CREATED_DATE, excluded.CREATED_DATE),
|
||||||
LAST_UPDATE_DATE = excluded.LAST_UPDATE_DATE
|
LAST_UPDATE_DATE = excluded.LAST_UPDATE_DATE
|
||||||
@@ -201,7 +198,7 @@ function upsertMonthlyRollupsForGroups(
|
|||||||
const upsertStmt = db.prepare(`
|
const upsertStmt = db.prepare(`
|
||||||
INSERT INTO imm_monthly_rollups (
|
INSERT INTO imm_monthly_rollups (
|
||||||
rollup_month, video_id, total_sessions, total_active_min, total_lines_seen,
|
rollup_month, video_id, total_sessions, total_active_min, total_lines_seen,
|
||||||
total_words_seen, total_tokens_seen, total_cards, CREATED_DATE, LAST_UPDATE_DATE
|
total_tokens_seen, total_cards, CREATED_DATE, LAST_UPDATE_DATE
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
CAST(strftime('%Y%m', s.started_at_ms / 1000, 'unixepoch', 'localtime') AS INTEGER) AS rollup_month,
|
CAST(strftime('%Y%m', s.started_at_ms / 1000, 'unixepoch', 'localtime') AS INTEGER) AS rollup_month,
|
||||||
@@ -209,7 +206,6 @@ function upsertMonthlyRollupsForGroups(
|
|||||||
COUNT(DISTINCT s.session_id) AS total_sessions,
|
COUNT(DISTINCT s.session_id) AS total_sessions,
|
||||||
COALESCE(SUM(sm.max_active_ms), 0) / 60000.0 AS total_active_min,
|
COALESCE(SUM(sm.max_active_ms), 0) / 60000.0 AS total_active_min,
|
||||||
COALESCE(SUM(sm.max_lines), 0) AS total_lines_seen,
|
COALESCE(SUM(sm.max_lines), 0) AS total_lines_seen,
|
||||||
COALESCE(SUM(sm.max_words), 0) AS total_words_seen,
|
|
||||||
COALESCE(SUM(sm.max_tokens), 0) AS total_tokens_seen,
|
COALESCE(SUM(sm.max_tokens), 0) AS total_tokens_seen,
|
||||||
COALESCE(SUM(sm.max_cards), 0) AS total_cards,
|
COALESCE(SUM(sm.max_cards), 0) AS total_cards,
|
||||||
? AS CREATED_DATE,
|
? AS CREATED_DATE,
|
||||||
@@ -220,7 +216,6 @@ function upsertMonthlyRollupsForGroups(
|
|||||||
t.session_id,
|
t.session_id,
|
||||||
MAX(t.active_watched_ms) AS max_active_ms,
|
MAX(t.active_watched_ms) AS max_active_ms,
|
||||||
MAX(t.lines_seen) AS max_lines,
|
MAX(t.lines_seen) AS max_lines,
|
||||||
MAX(t.words_seen) AS max_words,
|
|
||||||
MAX(t.tokens_seen) AS max_tokens,
|
MAX(t.tokens_seen) AS max_tokens,
|
||||||
MAX(t.cards_mined) AS max_cards
|
MAX(t.cards_mined) AS max_cards
|
||||||
FROM imm_session_telemetry t
|
FROM imm_session_telemetry t
|
||||||
@@ -232,7 +227,6 @@ function upsertMonthlyRollupsForGroups(
|
|||||||
total_sessions = excluded.total_sessions,
|
total_sessions = excluded.total_sessions,
|
||||||
total_active_min = excluded.total_active_min,
|
total_active_min = excluded.total_active_min,
|
||||||
total_lines_seen = excluded.total_lines_seen,
|
total_lines_seen = excluded.total_lines_seen,
|
||||||
total_words_seen = excluded.total_words_seen,
|
|
||||||
total_tokens_seen = excluded.total_tokens_seen,
|
total_tokens_seen = excluded.total_tokens_seen,
|
||||||
total_cards = excluded.total_cards,
|
total_cards = excluded.total_cards,
|
||||||
CREATED_DATE = COALESCE(imm_monthly_rollups.CREATED_DATE, excluded.CREATED_DATE),
|
CREATED_DATE = COALESCE(imm_monthly_rollups.CREATED_DATE, excluded.CREATED_DATE),
|
||||||
|
|||||||
@@ -77,7 +77,6 @@ const ACTIVE_SESSION_METRICS_CTE = `
|
|||||||
MAX(t.total_watched_ms) AS totalWatchedMs,
|
MAX(t.total_watched_ms) AS totalWatchedMs,
|
||||||
MAX(t.active_watched_ms) AS activeWatchedMs,
|
MAX(t.active_watched_ms) AS activeWatchedMs,
|
||||||
MAX(t.lines_seen) AS linesSeen,
|
MAX(t.lines_seen) AS linesSeen,
|
||||||
MAX(t.words_seen) AS wordsSeen,
|
|
||||||
MAX(t.tokens_seen) AS tokensSeen,
|
MAX(t.tokens_seen) AS tokensSeen,
|
||||||
MAX(t.cards_mined) AS cardsMined,
|
MAX(t.cards_mined) AS cardsMined,
|
||||||
MAX(t.lookup_count) AS lookupCount,
|
MAX(t.lookup_count) AS lookupCount,
|
||||||
@@ -353,7 +352,6 @@ export function getSessionSummaries(db: DatabaseSync, limit = 50): SessionSummar
|
|||||||
COALESCE(asm.totalWatchedMs, s.total_watched_ms, 0) AS totalWatchedMs,
|
COALESCE(asm.totalWatchedMs, s.total_watched_ms, 0) AS totalWatchedMs,
|
||||||
COALESCE(asm.activeWatchedMs, s.active_watched_ms, 0) AS activeWatchedMs,
|
COALESCE(asm.activeWatchedMs, s.active_watched_ms, 0) AS activeWatchedMs,
|
||||||
COALESCE(asm.linesSeen, s.lines_seen, 0) AS linesSeen,
|
COALESCE(asm.linesSeen, s.lines_seen, 0) AS linesSeen,
|
||||||
COALESCE(asm.wordsSeen, s.words_seen, 0) AS wordsSeen,
|
|
||||||
COALESCE(asm.tokensSeen, s.tokens_seen, 0) AS tokensSeen,
|
COALESCE(asm.tokensSeen, s.tokens_seen, 0) AS tokensSeen,
|
||||||
COALESCE(asm.cardsMined, s.cards_mined, 0) AS cardsMined,
|
COALESCE(asm.cardsMined, s.cards_mined, 0) AS cardsMined,
|
||||||
COALESCE(asm.lookupCount, s.lookup_count, 0) AS lookupCount,
|
COALESCE(asm.lookupCount, s.lookup_count, 0) AS lookupCount,
|
||||||
@@ -372,15 +370,30 @@ export function getSessionSummaries(db: DatabaseSync, limit = 50): SessionSummar
|
|||||||
export function getSessionTimeline(
|
export function getSessionTimeline(
|
||||||
db: DatabaseSync,
|
db: DatabaseSync,
|
||||||
sessionId: number,
|
sessionId: number,
|
||||||
limit = 200,
|
limit?: number,
|
||||||
): SessionTimelineRow[] {
|
): SessionTimelineRow[] {
|
||||||
|
if (limit === undefined) {
|
||||||
|
const prepared = db.prepare(`
|
||||||
|
SELECT
|
||||||
|
sample_ms AS sampleMs,
|
||||||
|
total_watched_ms AS totalWatchedMs,
|
||||||
|
active_watched_ms AS activeWatchedMs,
|
||||||
|
lines_seen AS linesSeen,
|
||||||
|
tokens_seen AS tokensSeen,
|
||||||
|
cards_mined AS cardsMined
|
||||||
|
FROM imm_session_telemetry
|
||||||
|
WHERE session_id = ?
|
||||||
|
ORDER BY sample_ms DESC, telemetry_id DESC
|
||||||
|
`);
|
||||||
|
return prepared.all(sessionId) as unknown as SessionTimelineRow[];
|
||||||
|
}
|
||||||
|
|
||||||
const prepared = db.prepare(`
|
const prepared = db.prepare(`
|
||||||
SELECT
|
SELECT
|
||||||
sample_ms AS sampleMs,
|
sample_ms AS sampleMs,
|
||||||
total_watched_ms AS totalWatchedMs,
|
total_watched_ms AS totalWatchedMs,
|
||||||
active_watched_ms AS activeWatchedMs,
|
active_watched_ms AS activeWatchedMs,
|
||||||
lines_seen AS linesSeen,
|
lines_seen AS linesSeen,
|
||||||
words_seen AS wordsSeen,
|
|
||||||
tokens_seen AS tokensSeen,
|
tokens_seen AS tokensSeen,
|
||||||
cards_mined AS cardsMined
|
cards_mined AS cardsMined
|
||||||
FROM imm_session_telemetry
|
FROM imm_session_telemetry
|
||||||
@@ -609,11 +622,10 @@ export function getDailyRollups(db: DatabaseSync, limit = 60): ImmersionSessionR
|
|||||||
r.total_sessions AS totalSessions,
|
r.total_sessions AS totalSessions,
|
||||||
r.total_active_min AS totalActiveMin,
|
r.total_active_min AS totalActiveMin,
|
||||||
r.total_lines_seen AS totalLinesSeen,
|
r.total_lines_seen AS totalLinesSeen,
|
||||||
r.total_words_seen AS totalWordsSeen,
|
|
||||||
r.total_tokens_seen AS totalTokensSeen,
|
r.total_tokens_seen AS totalTokensSeen,
|
||||||
r.total_cards AS totalCards,
|
r.total_cards AS totalCards,
|
||||||
r.cards_per_hour AS cardsPerHour,
|
r.cards_per_hour AS cardsPerHour,
|
||||||
r.words_per_min AS wordsPerMin,
|
r.tokens_per_min AS tokensPerMin,
|
||||||
r.lookup_hit_rate AS lookupHitRate
|
r.lookup_hit_rate AS lookupHitRate
|
||||||
FROM imm_daily_rollups r
|
FROM imm_daily_rollups r
|
||||||
WHERE r.rollup_day IN (SELECT rollup_day FROM recent_days)
|
WHERE r.rollup_day IN (SELECT rollup_day FROM recent_days)
|
||||||
@@ -637,11 +649,10 @@ export function getMonthlyRollups(db: DatabaseSync, limit = 24): ImmersionSessio
|
|||||||
total_sessions AS totalSessions,
|
total_sessions AS totalSessions,
|
||||||
total_active_min AS totalActiveMin,
|
total_active_min AS totalActiveMin,
|
||||||
total_lines_seen AS totalLinesSeen,
|
total_lines_seen AS totalLinesSeen,
|
||||||
total_words_seen AS totalWordsSeen,
|
|
||||||
total_tokens_seen AS totalTokensSeen,
|
total_tokens_seen AS totalTokensSeen,
|
||||||
total_cards AS totalCards,
|
total_cards AS totalCards,
|
||||||
0 AS cardsPerHour,
|
0 AS cardsPerHour,
|
||||||
0 AS wordsPerMin,
|
0 AS tokensPerMin,
|
||||||
0 AS lookupHitRate
|
0 AS lookupHitRate
|
||||||
FROM imm_monthly_rollups
|
FROM imm_monthly_rollups
|
||||||
WHERE rollup_month IN (SELECT rollup_month FROM recent_months)
|
WHERE rollup_month IN (SELECT rollup_month FROM recent_months)
|
||||||
@@ -670,7 +681,6 @@ interface TrendSessionMetricRow {
|
|||||||
canonicalTitle: string | null;
|
canonicalTitle: string | null;
|
||||||
animeTitle: string | null;
|
animeTitle: string | null;
|
||||||
activeWatchedMs: number;
|
activeWatchedMs: number;
|
||||||
wordsSeen: number;
|
|
||||||
tokensSeen: number;
|
tokensSeen: number;
|
||||||
cardsMined: number;
|
cardsMined: number;
|
||||||
yomitanLookupCount: number;
|
yomitanLookupCount: number;
|
||||||
@@ -760,10 +770,8 @@ function makeTrendLabel(value: number): string {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTrendSessionWordCount(
|
function getTrendSessionWordCount(session: Pick<TrendSessionMetricRow, 'tokensSeen'>): number {
|
||||||
session: Pick<TrendSessionMetricRow, 'wordsSeen' | 'tokensSeen'>,
|
return session.tokensSeen;
|
||||||
): number {
|
|
||||||
return session.tokensSeen > 0 ? session.tokensSeen : session.wordsSeen;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveTrendAnimeTitle(value: {
|
function resolveTrendAnimeTitle(value: {
|
||||||
@@ -796,7 +804,7 @@ function buildAggregatedTrendRows(rollups: ImmersionSessionRollupRow[]) {
|
|||||||
};
|
};
|
||||||
existing.activeMin += Math.round(rollup.totalActiveMin);
|
existing.activeMin += Math.round(rollup.totalActiveMin);
|
||||||
existing.cards += rollup.totalCards;
|
existing.cards += rollup.totalCards;
|
||||||
existing.words += rollup.totalWordsSeen;
|
existing.words += rollup.totalTokensSeen;
|
||||||
existing.sessions += rollup.totalSessions;
|
existing.sessions += rollup.totalSessions;
|
||||||
byKey.set(rollup.rollupDayOrMonth, existing);
|
byKey.set(rollup.rollupDayOrMonth, existing);
|
||||||
}
|
}
|
||||||
@@ -1087,7 +1095,6 @@ function getTrendSessionMetrics(
|
|||||||
v.canonical_title AS canonicalTitle,
|
v.canonical_title AS canonicalTitle,
|
||||||
a.canonical_title AS animeTitle,
|
a.canonical_title AS animeTitle,
|
||||||
COALESCE(asm.activeWatchedMs, s.active_watched_ms, 0) AS activeWatchedMs,
|
COALESCE(asm.activeWatchedMs, s.active_watched_ms, 0) AS activeWatchedMs,
|
||||||
COALESCE(asm.wordsSeen, s.words_seen, 0) AS wordsSeen,
|
|
||||||
COALESCE(asm.tokensSeen, s.tokens_seen, 0) AS tokensSeen,
|
COALESCE(asm.tokensSeen, s.tokens_seen, 0) AS tokensSeen,
|
||||||
COALESCE(asm.cardsMined, s.cards_mined, 0) AS cardsMined,
|
COALESCE(asm.cardsMined, s.cards_mined, 0) AS cardsMined,
|
||||||
COALESCE(asm.yomitanLookupCount, s.yomitan_lookup_count, 0) AS yomitanLookupCount
|
COALESCE(asm.yomitanLookupCount, s.yomitan_lookup_count, 0) AS yomitanLookupCount
|
||||||
@@ -1167,7 +1174,7 @@ export function getTrendsDashboard(
|
|||||||
words: buildPerAnimeFromDailyRollups(
|
words: buildPerAnimeFromDailyRollups(
|
||||||
dailyRollups,
|
dailyRollups,
|
||||||
titlesByVideoId,
|
titlesByVideoId,
|
||||||
(rollup) => rollup.totalWordsSeen,
|
(rollup) => rollup.totalTokensSeen,
|
||||||
),
|
),
|
||||||
lookups: buildPerAnimeFromSessions(
|
lookups: buildPerAnimeFromSessions(
|
||||||
sessions,
|
sessions,
|
||||||
@@ -1595,12 +1602,25 @@ export function getSessionEvents(
|
|||||||
db: DatabaseSync,
|
db: DatabaseSync,
|
||||||
sessionId: number,
|
sessionId: number,
|
||||||
limit = 500,
|
limit = 500,
|
||||||
|
eventTypes?: number[],
|
||||||
): SessionEventRow[] {
|
): SessionEventRow[] {
|
||||||
|
if (!eventTypes || eventTypes.length === 0) {
|
||||||
|
const stmt = db.prepare(`
|
||||||
|
SELECT event_type AS eventType, ts_ms AS tsMs, payload_json AS payload
|
||||||
|
FROM imm_session_events WHERE session_id = ? ORDER BY ts_ms ASC LIMIT ?
|
||||||
|
`);
|
||||||
|
return stmt.all(sessionId, limit) as SessionEventRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const placeholders = eventTypes.map(() => '?').join(', ');
|
||||||
const stmt = db.prepare(`
|
const stmt = db.prepare(`
|
||||||
SELECT event_type AS eventType, ts_ms AS tsMs, payload_json AS payload
|
SELECT event_type AS eventType, ts_ms AS tsMs, payload_json AS payload
|
||||||
FROM imm_session_events WHERE session_id = ? ORDER BY ts_ms ASC LIMIT ?
|
FROM imm_session_events
|
||||||
|
WHERE session_id = ? AND event_type IN (${placeholders})
|
||||||
|
ORDER BY ts_ms ASC
|
||||||
|
LIMIT ?
|
||||||
`);
|
`);
|
||||||
return stmt.all(sessionId, limit) as SessionEventRow[];
|
return stmt.all(sessionId, ...eventTypes, limit) as SessionEventRow[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAnimeLibrary(db: DatabaseSync): AnimeLibraryRow[] {
|
export function getAnimeLibrary(db: DatabaseSync): AnimeLibraryRow[] {
|
||||||
@@ -1614,7 +1634,7 @@ export function getAnimeLibrary(db: DatabaseSync): AnimeLibraryRow[] {
|
|||||||
COALESCE(lm.total_sessions, 0) AS totalSessions,
|
COALESCE(lm.total_sessions, 0) AS totalSessions,
|
||||||
COALESCE(lm.total_active_ms, 0) AS totalActiveMs,
|
COALESCE(lm.total_active_ms, 0) AS totalActiveMs,
|
||||||
COALESCE(lm.total_cards, 0) AS totalCards,
|
COALESCE(lm.total_cards, 0) AS totalCards,
|
||||||
COALESCE(lm.total_words_seen, 0) AS totalWordsSeen,
|
COALESCE(lm.total_tokens_seen, 0) AS totalTokensSeen,
|
||||||
COUNT(DISTINCT v.video_id) AS episodeCount,
|
COUNT(DISTINCT v.video_id) AS episodeCount,
|
||||||
a.episodes_total AS episodesTotal,
|
a.episodes_total AS episodesTotal,
|
||||||
COALESCE(lm.last_watched_ms, 0) AS lastWatchedMs
|
COALESCE(lm.last_watched_ms, 0) AS lastWatchedMs
|
||||||
@@ -1644,7 +1664,7 @@ export function getAnimeDetail(db: DatabaseSync, animeId: number): AnimeDetailRo
|
|||||||
COALESCE(lm.total_sessions, 0) AS totalSessions,
|
COALESCE(lm.total_sessions, 0) AS totalSessions,
|
||||||
COALESCE(lm.total_active_ms, 0) AS totalActiveMs,
|
COALESCE(lm.total_active_ms, 0) AS totalActiveMs,
|
||||||
COALESCE(lm.total_cards, 0) AS totalCards,
|
COALESCE(lm.total_cards, 0) AS totalCards,
|
||||||
COALESCE(lm.total_words_seen, 0) AS totalWordsSeen,
|
COALESCE(lm.total_tokens_seen, 0) AS totalTokensSeen,
|
||||||
COALESCE(lm.total_lines_seen, 0) AS totalLinesSeen,
|
COALESCE(lm.total_lines_seen, 0) AS totalLinesSeen,
|
||||||
COALESCE(SUM(COALESCE(asm.lookupCount, s.lookup_count, 0)), 0) AS totalLookupCount,
|
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.lookupHits, s.lookup_hits, 0)), 0) AS totalLookupHits,
|
||||||
@@ -1699,7 +1719,7 @@ export function getAnimeEpisodes(db: DatabaseSync, animeId: number): AnimeEpisod
|
|||||||
COUNT(DISTINCT s.session_id) AS totalSessions,
|
COUNT(DISTINCT s.session_id) AS totalSessions,
|
||||||
COALESCE(SUM(COALESCE(asm.activeWatchedMs, s.active_watched_ms, 0)), 0) AS totalActiveMs,
|
COALESCE(SUM(COALESCE(asm.activeWatchedMs, s.active_watched_ms, 0)), 0) AS totalActiveMs,
|
||||||
COALESCE(SUM(COALESCE(asm.cardsMined, s.cards_mined, 0)), 0) AS totalCards,
|
COALESCE(SUM(COALESCE(asm.cardsMined, s.cards_mined, 0)), 0) AS totalCards,
|
||||||
COALESCE(SUM(COALESCE(asm.wordsSeen, s.words_seen, 0)), 0) AS totalWordsSeen,
|
COALESCE(SUM(COALESCE(asm.tokensSeen, s.tokens_seen, 0)), 0) AS totalTokensSeen,
|
||||||
COALESCE(SUM(COALESCE(asm.yomitanLookupCount, s.yomitan_lookup_count, 0)), 0) AS totalYomitanLookupCount,
|
COALESCE(SUM(COALESCE(asm.yomitanLookupCount, s.yomitan_lookup_count, 0)), 0) AS totalYomitanLookupCount,
|
||||||
MAX(s.started_at_ms) AS lastWatchedMs
|
MAX(s.started_at_ms) AS lastWatchedMs
|
||||||
FROM imm_videos v
|
FROM imm_videos v
|
||||||
@@ -1728,7 +1748,7 @@ export function getMediaLibrary(db: DatabaseSync): MediaLibraryRow[] {
|
|||||||
COALESCE(lm.total_sessions, 0) AS totalSessions,
|
COALESCE(lm.total_sessions, 0) AS totalSessions,
|
||||||
COALESCE(lm.total_active_ms, 0) AS totalActiveMs,
|
COALESCE(lm.total_active_ms, 0) AS totalActiveMs,
|
||||||
COALESCE(lm.total_cards, 0) AS totalCards,
|
COALESCE(lm.total_cards, 0) AS totalCards,
|
||||||
COALESCE(lm.total_words_seen, 0) AS totalWordsSeen,
|
COALESCE(lm.total_tokens_seen, 0) AS totalTokensSeen,
|
||||||
COALESCE(lm.last_watched_ms, 0) AS lastWatchedMs,
|
COALESCE(lm.last_watched_ms, 0) AS lastWatchedMs,
|
||||||
CASE
|
CASE
|
||||||
WHEN ma.cover_blob_hash IS NOT NULL OR ma.cover_blob IS NOT NULL THEN 1
|
WHEN ma.cover_blob_hash IS NOT NULL OR ma.cover_blob IS NOT NULL THEN 1
|
||||||
@@ -1754,7 +1774,7 @@ export function getMediaDetail(db: DatabaseSync, videoId: number): MediaDetailRo
|
|||||||
COALESCE(lm.total_sessions, 0) AS totalSessions,
|
COALESCE(lm.total_sessions, 0) AS totalSessions,
|
||||||
COALESCE(lm.total_active_ms, 0) AS totalActiveMs,
|
COALESCE(lm.total_active_ms, 0) AS totalActiveMs,
|
||||||
COALESCE(lm.total_cards, 0) AS totalCards,
|
COALESCE(lm.total_cards, 0) AS totalCards,
|
||||||
COALESCE(lm.total_words_seen, 0) AS totalWordsSeen,
|
COALESCE(lm.total_tokens_seen, 0) AS totalTokensSeen,
|
||||||
COALESCE(lm.total_lines_seen, 0) AS totalLinesSeen,
|
COALESCE(lm.total_lines_seen, 0) AS totalLinesSeen,
|
||||||
COALESCE(SUM(COALESCE(asm.lookupCount, s.lookup_count, 0)), 0) AS totalLookupCount,
|
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.lookupHits, s.lookup_hits, 0)), 0) AS totalLookupHits,
|
||||||
@@ -1788,7 +1808,6 @@ export function getMediaSessions(
|
|||||||
COALESCE(asm.totalWatchedMs, s.total_watched_ms, 0) AS totalWatchedMs,
|
COALESCE(asm.totalWatchedMs, s.total_watched_ms, 0) AS totalWatchedMs,
|
||||||
COALESCE(asm.activeWatchedMs, s.active_watched_ms, 0) AS activeWatchedMs,
|
COALESCE(asm.activeWatchedMs, s.active_watched_ms, 0) AS activeWatchedMs,
|
||||||
COALESCE(asm.linesSeen, s.lines_seen, 0) AS linesSeen,
|
COALESCE(asm.linesSeen, s.lines_seen, 0) AS linesSeen,
|
||||||
COALESCE(asm.wordsSeen, s.words_seen, 0) AS wordsSeen,
|
|
||||||
COALESCE(asm.tokensSeen, s.tokens_seen, 0) AS tokensSeen,
|
COALESCE(asm.tokensSeen, s.tokens_seen, 0) AS tokensSeen,
|
||||||
COALESCE(asm.cardsMined, s.cards_mined, 0) AS cardsMined,
|
COALESCE(asm.cardsMined, s.cards_mined, 0) AS cardsMined,
|
||||||
COALESCE(asm.lookupCount, s.lookup_count, 0) AS lookupCount,
|
COALESCE(asm.lookupCount, s.lookup_count, 0) AS lookupCount,
|
||||||
@@ -1826,11 +1845,10 @@ export function getMediaDailyRollups(
|
|||||||
total_sessions AS totalSessions,
|
total_sessions AS totalSessions,
|
||||||
total_active_min AS totalActiveMin,
|
total_active_min AS totalActiveMin,
|
||||||
total_lines_seen AS totalLinesSeen,
|
total_lines_seen AS totalLinesSeen,
|
||||||
total_words_seen AS totalWordsSeen,
|
|
||||||
total_tokens_seen AS totalTokensSeen,
|
total_tokens_seen AS totalTokensSeen,
|
||||||
total_cards AS totalCards,
|
total_cards AS totalCards,
|
||||||
cards_per_hour AS cardsPerHour,
|
cards_per_hour AS cardsPerHour,
|
||||||
words_per_min AS wordsPerMin,
|
tokens_per_min AS tokensPerMin,
|
||||||
lookup_hit_rate AS lookupHitRate
|
lookup_hit_rate AS lookupHitRate
|
||||||
FROM imm_daily_rollups
|
FROM imm_daily_rollups
|
||||||
WHERE video_id = ?
|
WHERE video_id = ?
|
||||||
@@ -1859,9 +1877,9 @@ export function getAnimeDailyRollups(
|
|||||||
)
|
)
|
||||||
SELECT r.rollup_day AS rollupDayOrMonth, r.video_id AS videoId,
|
SELECT r.rollup_day AS rollupDayOrMonth, r.video_id AS videoId,
|
||||||
r.total_sessions AS totalSessions, r.total_active_min AS totalActiveMin,
|
r.total_sessions AS totalSessions, r.total_active_min AS totalActiveMin,
|
||||||
r.total_lines_seen AS totalLinesSeen, r.total_words_seen AS totalWordsSeen,
|
r.total_lines_seen AS totalLinesSeen,
|
||||||
r.total_tokens_seen AS totalTokensSeen, r.total_cards AS totalCards,
|
r.total_tokens_seen AS totalTokensSeen, r.total_cards AS totalCards,
|
||||||
r.cards_per_hour AS cardsPerHour, r.words_per_min AS wordsPerMin,
|
r.cards_per_hour AS cardsPerHour, r.tokens_per_min AS tokensPerMin,
|
||||||
r.lookup_hit_rate AS lookupHitRate
|
r.lookup_hit_rate AS lookupHitRate
|
||||||
FROM imm_daily_rollups r
|
FROM imm_daily_rollups r
|
||||||
JOIN imm_videos v ON v.video_id = r.video_id
|
JOIN imm_videos v ON v.video_id = r.video_id
|
||||||
@@ -2153,7 +2171,6 @@ export function getEpisodeSessions(db: DatabaseSync, videoId: number): SessionSu
|
|||||||
COALESCE(asm.totalWatchedMs, s.total_watched_ms, 0) AS totalWatchedMs,
|
COALESCE(asm.totalWatchedMs, s.total_watched_ms, 0) AS totalWatchedMs,
|
||||||
COALESCE(asm.activeWatchedMs, s.active_watched_ms, 0) AS activeWatchedMs,
|
COALESCE(asm.activeWatchedMs, s.active_watched_ms, 0) AS activeWatchedMs,
|
||||||
COALESCE(asm.linesSeen, s.lines_seen, 0) AS linesSeen,
|
COALESCE(asm.linesSeen, s.lines_seen, 0) AS linesSeen,
|
||||||
COALESCE(asm.wordsSeen, s.words_seen, 0) AS wordsSeen,
|
|
||||||
COALESCE(asm.tokensSeen, s.tokens_seen, 0) AS tokensSeen,
|
COALESCE(asm.tokensSeen, s.tokens_seen, 0) AS tokensSeen,
|
||||||
COALESCE(asm.cardsMined, s.cards_mined, 0) AS cardsMined,
|
COALESCE(asm.cardsMined, s.cards_mined, 0) AS cardsMined,
|
||||||
COALESCE(asm.lookupCount, s.lookup_count, 0) AS lookupCount,
|
COALESCE(asm.lookupCount, s.lookup_count, 0) AS lookupCount,
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ export function createInitialSessionState(
|
|||||||
totalWatchedMs: 0,
|
totalWatchedMs: 0,
|
||||||
activeWatchedMs: 0,
|
activeWatchedMs: 0,
|
||||||
linesSeen: 0,
|
linesSeen: 0,
|
||||||
wordsSeen: 0,
|
|
||||||
tokensSeen: 0,
|
tokensSeen: 0,
|
||||||
cardsMined: 0,
|
cardsMined: 0,
|
||||||
lookupCount: 0,
|
lookupCount: 0,
|
||||||
@@ -52,16 +51,6 @@ export function sanitizePayload(payload: Record<string, unknown>, maxPayloadByte
|
|||||||
return json.length <= maxPayloadBytes ? json : JSON.stringify({ truncated: true });
|
return json.length <= maxPayloadBytes ? json : JSON.stringify({ truncated: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function calculateTextMetrics(value: string): {
|
|
||||||
words: number;
|
|
||||||
tokens: number;
|
|
||||||
} {
|
|
||||||
const words = value.split(/\s+/).filter(Boolean).length;
|
|
||||||
const cjkCount = value.match(/[\u3040-\u30ff\u4e00-\u9fff]/g)?.length ?? 0;
|
|
||||||
const tokens = Math.max(words, cjkCount);
|
|
||||||
return { words, tokens };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function secToMs(seconds: number): number {
|
export function secToMs(seconds: number): number {
|
||||||
const coerced = Number(seconds);
|
const coerced = Number(seconds);
|
||||||
if (!Number.isFinite(coerced)) return 0;
|
if (!Number.isFinite(coerced)) return 0;
|
||||||
|
|||||||
@@ -42,7 +42,6 @@ export function finalizeSessionRecord(
|
|||||||
total_watched_ms = ?,
|
total_watched_ms = ?,
|
||||||
active_watched_ms = ?,
|
active_watched_ms = ?,
|
||||||
lines_seen = ?,
|
lines_seen = ?,
|
||||||
words_seen = ?,
|
|
||||||
tokens_seen = ?,
|
tokens_seen = ?,
|
||||||
cards_mined = ?,
|
cards_mined = ?,
|
||||||
lookup_count = ?,
|
lookup_count = ?,
|
||||||
@@ -62,7 +61,6 @@ export function finalizeSessionRecord(
|
|||||||
sessionState.totalWatchedMs,
|
sessionState.totalWatchedMs,
|
||||||
sessionState.activeWatchedMs,
|
sessionState.activeWatchedMs,
|
||||||
sessionState.linesSeen,
|
sessionState.linesSeen,
|
||||||
sessionState.wordsSeen,
|
|
||||||
sessionState.tokensSeen,
|
sessionState.tokensSeen,
|
||||||
sessionState.cardsMined,
|
sessionState.cardsMined,
|
||||||
sessionState.lookupCount,
|
sessionState.lookupCount,
|
||||||
|
|||||||
@@ -763,7 +763,6 @@ test('executeQueuedWrite inserts event and telemetry rows', () => {
|
|||||||
totalWatchedMs: 1_000,
|
totalWatchedMs: 1_000,
|
||||||
activeWatchedMs: 900,
|
activeWatchedMs: 900,
|
||||||
linesSeen: 3,
|
linesSeen: 3,
|
||||||
wordsSeen: 6,
|
|
||||||
tokensSeen: 6,
|
tokensSeen: 6,
|
||||||
cardsMined: 1,
|
cardsMined: 1,
|
||||||
lookupCount: 2,
|
lookupCount: 2,
|
||||||
@@ -786,7 +785,7 @@ test('executeQueuedWrite inserts event and telemetry rows', () => {
|
|||||||
lineIndex: 1,
|
lineIndex: 1,
|
||||||
segmentStartMs: 0,
|
segmentStartMs: 0,
|
||||||
segmentEndMs: 800,
|
segmentEndMs: 800,
|
||||||
wordsDelta: 2,
|
tokensDelta: 2,
|
||||||
cardsDelta: 0,
|
cardsDelta: 0,
|
||||||
payloadJson: '{"event":"subtitle-line"}',
|
payloadJson: '{"event":"subtitle-line"}',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -290,7 +290,6 @@ function ensureLifetimeSummaryTables(db: DatabaseSync): void {
|
|||||||
total_sessions INTEGER NOT NULL DEFAULT 0,
|
total_sessions INTEGER NOT NULL DEFAULT 0,
|
||||||
total_active_ms INTEGER NOT NULL DEFAULT 0,
|
total_active_ms INTEGER NOT NULL DEFAULT 0,
|
||||||
total_cards INTEGER NOT NULL DEFAULT 0,
|
total_cards INTEGER NOT NULL DEFAULT 0,
|
||||||
total_words_seen INTEGER NOT NULL DEFAULT 0,
|
|
||||||
total_lines_seen INTEGER NOT NULL DEFAULT 0,
|
total_lines_seen INTEGER NOT NULL DEFAULT 0,
|
||||||
total_tokens_seen INTEGER NOT NULL DEFAULT 0,
|
total_tokens_seen INTEGER NOT NULL DEFAULT 0,
|
||||||
episodes_started INTEGER NOT NULL DEFAULT 0,
|
episodes_started INTEGER NOT NULL DEFAULT 0,
|
||||||
@@ -309,7 +308,6 @@ function ensureLifetimeSummaryTables(db: DatabaseSync): void {
|
|||||||
total_sessions INTEGER NOT NULL DEFAULT 0,
|
total_sessions INTEGER NOT NULL DEFAULT 0,
|
||||||
total_active_ms INTEGER NOT NULL DEFAULT 0,
|
total_active_ms INTEGER NOT NULL DEFAULT 0,
|
||||||
total_cards INTEGER NOT NULL DEFAULT 0,
|
total_cards INTEGER NOT NULL DEFAULT 0,
|
||||||
total_words_seen INTEGER NOT NULL DEFAULT 0,
|
|
||||||
total_lines_seen INTEGER NOT NULL DEFAULT 0,
|
total_lines_seen INTEGER NOT NULL DEFAULT 0,
|
||||||
total_tokens_seen INTEGER NOT NULL DEFAULT 0,
|
total_tokens_seen INTEGER NOT NULL DEFAULT 0,
|
||||||
completed INTEGER NOT NULL DEFAULT 0,
|
completed INTEGER NOT NULL DEFAULT 0,
|
||||||
@@ -574,7 +572,6 @@ export function ensureSchema(db: DatabaseSync): void {
|
|||||||
total_watched_ms INTEGER NOT NULL DEFAULT 0,
|
total_watched_ms INTEGER NOT NULL DEFAULT 0,
|
||||||
active_watched_ms INTEGER NOT NULL DEFAULT 0,
|
active_watched_ms INTEGER NOT NULL DEFAULT 0,
|
||||||
lines_seen INTEGER NOT NULL DEFAULT 0,
|
lines_seen INTEGER NOT NULL DEFAULT 0,
|
||||||
words_seen INTEGER NOT NULL DEFAULT 0,
|
|
||||||
tokens_seen INTEGER NOT NULL DEFAULT 0,
|
tokens_seen INTEGER NOT NULL DEFAULT 0,
|
||||||
cards_mined INTEGER NOT NULL DEFAULT 0,
|
cards_mined INTEGER NOT NULL DEFAULT 0,
|
||||||
lookup_count INTEGER NOT NULL DEFAULT 0,
|
lookup_count INTEGER NOT NULL DEFAULT 0,
|
||||||
@@ -598,7 +595,6 @@ export function ensureSchema(db: DatabaseSync): void {
|
|||||||
total_watched_ms INTEGER NOT NULL DEFAULT 0,
|
total_watched_ms INTEGER NOT NULL DEFAULT 0,
|
||||||
active_watched_ms INTEGER NOT NULL DEFAULT 0,
|
active_watched_ms INTEGER NOT NULL DEFAULT 0,
|
||||||
lines_seen INTEGER NOT NULL DEFAULT 0,
|
lines_seen INTEGER NOT NULL DEFAULT 0,
|
||||||
words_seen INTEGER NOT NULL DEFAULT 0,
|
|
||||||
tokens_seen INTEGER NOT NULL DEFAULT 0,
|
tokens_seen INTEGER NOT NULL DEFAULT 0,
|
||||||
cards_mined INTEGER NOT NULL DEFAULT 0,
|
cards_mined INTEGER NOT NULL DEFAULT 0,
|
||||||
lookup_count INTEGER NOT NULL DEFAULT 0,
|
lookup_count INTEGER NOT NULL DEFAULT 0,
|
||||||
@@ -623,7 +619,7 @@ export function ensureSchema(db: DatabaseSync): void {
|
|||||||
line_index INTEGER,
|
line_index INTEGER,
|
||||||
segment_start_ms INTEGER,
|
segment_start_ms INTEGER,
|
||||||
segment_end_ms INTEGER,
|
segment_end_ms INTEGER,
|
||||||
words_delta INTEGER NOT NULL DEFAULT 0,
|
tokens_delta INTEGER NOT NULL DEFAULT 0,
|
||||||
cards_delta INTEGER NOT NULL DEFAULT 0,
|
cards_delta INTEGER NOT NULL DEFAULT 0,
|
||||||
payload_json TEXT,
|
payload_json TEXT,
|
||||||
CREATED_DATE INTEGER,
|
CREATED_DATE INTEGER,
|
||||||
@@ -638,11 +634,10 @@ export function ensureSchema(db: DatabaseSync): void {
|
|||||||
total_sessions INTEGER NOT NULL DEFAULT 0,
|
total_sessions INTEGER NOT NULL DEFAULT 0,
|
||||||
total_active_min REAL NOT NULL DEFAULT 0,
|
total_active_min REAL NOT NULL DEFAULT 0,
|
||||||
total_lines_seen INTEGER NOT NULL DEFAULT 0,
|
total_lines_seen INTEGER NOT NULL DEFAULT 0,
|
||||||
total_words_seen INTEGER NOT NULL DEFAULT 0,
|
|
||||||
total_tokens_seen INTEGER NOT NULL DEFAULT 0,
|
total_tokens_seen INTEGER NOT NULL DEFAULT 0,
|
||||||
total_cards INTEGER NOT NULL DEFAULT 0,
|
total_cards INTEGER NOT NULL DEFAULT 0,
|
||||||
cards_per_hour REAL,
|
cards_per_hour REAL,
|
||||||
words_per_min REAL,
|
tokens_per_min REAL,
|
||||||
lookup_hit_rate REAL,
|
lookup_hit_rate REAL,
|
||||||
CREATED_DATE INTEGER,
|
CREATED_DATE INTEGER,
|
||||||
LAST_UPDATE_DATE INTEGER,
|
LAST_UPDATE_DATE INTEGER,
|
||||||
@@ -656,7 +651,6 @@ export function ensureSchema(db: DatabaseSync): void {
|
|||||||
total_sessions INTEGER NOT NULL DEFAULT 0,
|
total_sessions INTEGER NOT NULL DEFAULT 0,
|
||||||
total_active_min REAL NOT NULL DEFAULT 0,
|
total_active_min REAL NOT NULL DEFAULT 0,
|
||||||
total_lines_seen INTEGER NOT NULL DEFAULT 0,
|
total_lines_seen INTEGER NOT NULL DEFAULT 0,
|
||||||
total_words_seen INTEGER NOT NULL DEFAULT 0,
|
|
||||||
total_tokens_seen INTEGER NOT NULL DEFAULT 0,
|
total_tokens_seen INTEGER NOT NULL DEFAULT 0,
|
||||||
total_cards INTEGER NOT NULL DEFAULT 0,
|
total_cards INTEGER NOT NULL DEFAULT 0,
|
||||||
CREATED_DATE INTEGER,
|
CREATED_DATE INTEGER,
|
||||||
@@ -895,7 +889,6 @@ export function ensureSchema(db: DatabaseSync): void {
|
|||||||
addColumnIfMissing(db, 'imm_sessions', 'total_watched_ms', 'INTEGER NOT NULL DEFAULT 0');
|
addColumnIfMissing(db, 'imm_sessions', 'total_watched_ms', 'INTEGER NOT NULL DEFAULT 0');
|
||||||
addColumnIfMissing(db, 'imm_sessions', 'active_watched_ms', 'INTEGER NOT NULL DEFAULT 0');
|
addColumnIfMissing(db, 'imm_sessions', 'active_watched_ms', 'INTEGER NOT NULL DEFAULT 0');
|
||||||
addColumnIfMissing(db, 'imm_sessions', 'lines_seen', 'INTEGER NOT NULL DEFAULT 0');
|
addColumnIfMissing(db, 'imm_sessions', 'lines_seen', 'INTEGER NOT NULL DEFAULT 0');
|
||||||
addColumnIfMissing(db, 'imm_sessions', 'words_seen', 'INTEGER NOT NULL DEFAULT 0');
|
|
||||||
addColumnIfMissing(db, 'imm_sessions', 'tokens_seen', 'INTEGER NOT NULL DEFAULT 0');
|
addColumnIfMissing(db, 'imm_sessions', 'tokens_seen', 'INTEGER NOT NULL DEFAULT 0');
|
||||||
addColumnIfMissing(db, 'imm_sessions', 'cards_mined', 'INTEGER NOT NULL DEFAULT 0');
|
addColumnIfMissing(db, 'imm_sessions', 'cards_mined', 'INTEGER NOT NULL DEFAULT 0');
|
||||||
addColumnIfMissing(db, 'imm_sessions', 'lookup_count', 'INTEGER NOT NULL DEFAULT 0');
|
addColumnIfMissing(db, 'imm_sessions', 'lookup_count', 'INTEGER NOT NULL DEFAULT 0');
|
||||||
@@ -930,13 +923,6 @@ export function ensureSchema(db: DatabaseSync): void {
|
|||||||
ORDER BY t.sample_ms DESC, t.telemetry_id DESC
|
ORDER BY t.sample_ms DESC, t.telemetry_id DESC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
), lines_seen),
|
), lines_seen),
|
||||||
words_seen = COALESCE((
|
|
||||||
SELECT t.words_seen
|
|
||||||
FROM imm_session_telemetry t
|
|
||||||
WHERE t.session_id = imm_sessions.session_id
|
|
||||||
ORDER BY t.sample_ms DESC, t.telemetry_id DESC
|
|
||||||
LIMIT 1
|
|
||||||
), words_seen),
|
|
||||||
tokens_seen = COALESCE((
|
tokens_seen = COALESCE((
|
||||||
SELECT t.tokens_seen
|
SELECT t.tokens_seen
|
||||||
FROM imm_session_telemetry t
|
FROM imm_session_telemetry t
|
||||||
@@ -1163,17 +1149,17 @@ export function createTrackerPreparedStatements(db: DatabaseSync): TrackerPrepar
|
|||||||
telemetryInsertStmt: db.prepare(`
|
telemetryInsertStmt: db.prepare(`
|
||||||
INSERT INTO imm_session_telemetry (
|
INSERT INTO imm_session_telemetry (
|
||||||
session_id, sample_ms, total_watched_ms, active_watched_ms,
|
session_id, sample_ms, total_watched_ms, active_watched_ms,
|
||||||
lines_seen, words_seen, tokens_seen, cards_mined, lookup_count,
|
lines_seen, tokens_seen, cards_mined, lookup_count,
|
||||||
lookup_hits, yomitan_lookup_count, pause_count, pause_ms, seek_forward_count,
|
lookup_hits, yomitan_lookup_count, pause_count, pause_ms, seek_forward_count,
|
||||||
seek_backward_count, media_buffer_events, CREATED_DATE, LAST_UPDATE_DATE
|
seek_backward_count, media_buffer_events, CREATED_DATE, LAST_UPDATE_DATE
|
||||||
) VALUES (
|
) VALUES (
|
||||||
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
||||||
)
|
)
|
||||||
`),
|
`),
|
||||||
eventInsertStmt: db.prepare(`
|
eventInsertStmt: db.prepare(`
|
||||||
INSERT INTO imm_session_events (
|
INSERT INTO imm_session_events (
|
||||||
session_id, ts_ms, event_type, line_index, segment_start_ms, segment_end_ms,
|
session_id, ts_ms, event_type, line_index, segment_start_ms, segment_end_ms,
|
||||||
words_delta, cards_delta, payload_json, CREATED_DATE, LAST_UPDATE_DATE
|
tokens_delta, cards_delta, payload_json, CREATED_DATE, LAST_UPDATE_DATE
|
||||||
) VALUES (
|
) VALUES (
|
||||||
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
||||||
)
|
)
|
||||||
@@ -1310,7 +1296,6 @@ export function executeQueuedWrite(write: QueuedWrite, stmts: TrackerPreparedSta
|
|||||||
write.totalWatchedMs!,
|
write.totalWatchedMs!,
|
||||||
write.activeWatchedMs!,
|
write.activeWatchedMs!,
|
||||||
write.linesSeen!,
|
write.linesSeen!,
|
||||||
write.wordsSeen!,
|
|
||||||
write.tokensSeen!,
|
write.tokensSeen!,
|
||||||
write.cardsMined!,
|
write.cardsMined!,
|
||||||
write.lookupCount!,
|
write.lookupCount!,
|
||||||
@@ -1381,7 +1366,7 @@ export function executeQueuedWrite(write: QueuedWrite, stmts: TrackerPreparedSta
|
|||||||
write.lineIndex ?? null,
|
write.lineIndex ?? null,
|
||||||
write.segmentStartMs ?? null,
|
write.segmentStartMs ?? null,
|
||||||
write.segmentEndMs ?? null,
|
write.segmentEndMs ?? null,
|
||||||
write.wordsDelta ?? 0,
|
write.tokensDelta ?? 0,
|
||||||
write.cardsDelta ?? 0,
|
write.cardsDelta ?? 0,
|
||||||
write.payloadJson ?? null,
|
write.payloadJson ?? null,
|
||||||
Date.now(),
|
Date.now(),
|
||||||
|
|||||||
@@ -56,7 +56,6 @@ export interface TelemetryAccumulator {
|
|||||||
totalWatchedMs: number;
|
totalWatchedMs: number;
|
||||||
activeWatchedMs: number;
|
activeWatchedMs: number;
|
||||||
linesSeen: number;
|
linesSeen: number;
|
||||||
wordsSeen: number;
|
|
||||||
tokensSeen: number;
|
tokensSeen: number;
|
||||||
cardsMined: number;
|
cardsMined: number;
|
||||||
lookupCount: number;
|
lookupCount: number;
|
||||||
@@ -89,7 +88,6 @@ interface QueuedTelemetryWrite {
|
|||||||
totalWatchedMs?: number;
|
totalWatchedMs?: number;
|
||||||
activeWatchedMs?: number;
|
activeWatchedMs?: number;
|
||||||
linesSeen?: number;
|
linesSeen?: number;
|
||||||
wordsSeen?: number;
|
|
||||||
tokensSeen?: number;
|
tokensSeen?: number;
|
||||||
cardsMined?: number;
|
cardsMined?: number;
|
||||||
lookupCount?: number;
|
lookupCount?: number;
|
||||||
@@ -104,7 +102,7 @@ interface QueuedTelemetryWrite {
|
|||||||
lineIndex?: number | null;
|
lineIndex?: number | null;
|
||||||
segmentStartMs?: number | null;
|
segmentStartMs?: number | null;
|
||||||
segmentEndMs?: number | null;
|
segmentEndMs?: number | null;
|
||||||
wordsDelta?: number;
|
tokensDelta?: number;
|
||||||
cardsDelta?: number;
|
cardsDelta?: number;
|
||||||
payloadJson?: string | null;
|
payloadJson?: string | null;
|
||||||
}
|
}
|
||||||
@@ -117,7 +115,7 @@ interface QueuedEventWrite {
|
|||||||
lineIndex?: number | null;
|
lineIndex?: number | null;
|
||||||
segmentStartMs?: number | null;
|
segmentStartMs?: number | null;
|
||||||
segmentEndMs?: number | null;
|
segmentEndMs?: number | null;
|
||||||
wordsDelta?: number;
|
tokensDelta?: number;
|
||||||
cardsDelta?: number;
|
cardsDelta?: number;
|
||||||
payloadJson?: string | null;
|
payloadJson?: string | null;
|
||||||
}
|
}
|
||||||
@@ -231,7 +229,6 @@ export interface SessionSummaryQueryRow {
|
|||||||
totalWatchedMs: number;
|
totalWatchedMs: number;
|
||||||
activeWatchedMs: number;
|
activeWatchedMs: number;
|
||||||
linesSeen: number;
|
linesSeen: number;
|
||||||
wordsSeen: number;
|
|
||||||
tokensSeen: number;
|
tokensSeen: number;
|
||||||
cardsMined: number;
|
cardsMined: number;
|
||||||
lookupCount: number;
|
lookupCount: number;
|
||||||
@@ -255,7 +252,6 @@ export interface LifetimeAnimeRow {
|
|||||||
totalSessions: number;
|
totalSessions: number;
|
||||||
totalActiveMs: number;
|
totalActiveMs: number;
|
||||||
totalCards: number;
|
totalCards: number;
|
||||||
totalWordsSeen: number;
|
|
||||||
totalLinesSeen: number;
|
totalLinesSeen: number;
|
||||||
totalTokensSeen: number;
|
totalTokensSeen: number;
|
||||||
episodesStarted: number;
|
episodesStarted: number;
|
||||||
@@ -269,7 +265,6 @@ export interface LifetimeMediaRow {
|
|||||||
totalSessions: number;
|
totalSessions: number;
|
||||||
totalActiveMs: number;
|
totalActiveMs: number;
|
||||||
totalCards: number;
|
totalCards: number;
|
||||||
totalWordsSeen: number;
|
|
||||||
totalLinesSeen: number;
|
totalLinesSeen: number;
|
||||||
totalTokensSeen: number;
|
totalTokensSeen: number;
|
||||||
completed: number;
|
completed: number;
|
||||||
@@ -374,7 +369,6 @@ export interface SessionTimelineRow {
|
|||||||
totalWatchedMs: number;
|
totalWatchedMs: number;
|
||||||
activeWatchedMs: number;
|
activeWatchedMs: number;
|
||||||
linesSeen: number;
|
linesSeen: number;
|
||||||
wordsSeen: number;
|
|
||||||
tokensSeen: number;
|
tokensSeen: number;
|
||||||
cardsMined: number;
|
cardsMined: number;
|
||||||
}
|
}
|
||||||
@@ -385,11 +379,10 @@ export interface ImmersionSessionRollupRow {
|
|||||||
totalSessions: number;
|
totalSessions: number;
|
||||||
totalActiveMin: number;
|
totalActiveMin: number;
|
||||||
totalLinesSeen: number;
|
totalLinesSeen: number;
|
||||||
totalWordsSeen: number;
|
|
||||||
totalTokensSeen: number;
|
totalTokensSeen: number;
|
||||||
totalCards: number;
|
totalCards: number;
|
||||||
cardsPerHour: number | null;
|
cardsPerHour: number | null;
|
||||||
wordsPerMin: number | null;
|
tokensPerMin: number | null;
|
||||||
lookupHitRate: number | null;
|
lookupHitRate: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -421,7 +414,7 @@ export interface MediaLibraryRow {
|
|||||||
totalSessions: number;
|
totalSessions: number;
|
||||||
totalActiveMs: number;
|
totalActiveMs: number;
|
||||||
totalCards: number;
|
totalCards: number;
|
||||||
totalWordsSeen: number;
|
totalTokensSeen: number;
|
||||||
lastWatchedMs: number;
|
lastWatchedMs: number;
|
||||||
hasCoverArt: number;
|
hasCoverArt: number;
|
||||||
}
|
}
|
||||||
@@ -432,7 +425,7 @@ export interface MediaDetailRow {
|
|||||||
totalSessions: number;
|
totalSessions: number;
|
||||||
totalActiveMs: number;
|
totalActiveMs: number;
|
||||||
totalCards: number;
|
totalCards: number;
|
||||||
totalWordsSeen: number;
|
totalTokensSeen: number;
|
||||||
totalLinesSeen: number;
|
totalLinesSeen: number;
|
||||||
totalLookupCount: number;
|
totalLookupCount: number;
|
||||||
totalLookupHits: number;
|
totalLookupHits: number;
|
||||||
@@ -446,7 +439,7 @@ export interface AnimeLibraryRow {
|
|||||||
totalSessions: number;
|
totalSessions: number;
|
||||||
totalActiveMs: number;
|
totalActiveMs: number;
|
||||||
totalCards: number;
|
totalCards: number;
|
||||||
totalWordsSeen: number;
|
totalTokensSeen: number;
|
||||||
episodeCount: number;
|
episodeCount: number;
|
||||||
episodesTotal: number | null;
|
episodesTotal: number | null;
|
||||||
lastWatchedMs: number;
|
lastWatchedMs: number;
|
||||||
@@ -463,7 +456,7 @@ export interface AnimeDetailRow {
|
|||||||
totalSessions: number;
|
totalSessions: number;
|
||||||
totalActiveMs: number;
|
totalActiveMs: number;
|
||||||
totalCards: number;
|
totalCards: number;
|
||||||
totalWordsSeen: number;
|
totalTokensSeen: number;
|
||||||
totalLinesSeen: number;
|
totalLinesSeen: number;
|
||||||
totalLookupCount: number;
|
totalLookupCount: number;
|
||||||
totalLookupHits: number;
|
totalLookupHits: number;
|
||||||
@@ -491,7 +484,7 @@ export interface AnimeEpisodeRow {
|
|||||||
totalSessions: number;
|
totalSessions: number;
|
||||||
totalActiveMs: number;
|
totalActiveMs: number;
|
||||||
totalCards: number;
|
totalCards: number;
|
||||||
totalWordsSeen: number;
|
totalTokensSeen: number;
|
||||||
totalYomitanLookupCount: number;
|
totalYomitanLookupCount: number;
|
||||||
lastWatchedMs: number;
|
lastWatchedMs: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,20 +37,12 @@ function Metric({ label, value, unit, color, tooltip, sub }: MetricProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AnimeOverviewStats({
|
export function AnimeOverviewStats({ detail, knownWordsSummary }: AnimeOverviewStatsProps) {
|
||||||
detail,
|
const lookupRate = buildLookupRateDisplay(detail.totalYomitanLookupCount, detail.totalTokensSeen);
|
||||||
knownWordsSummary,
|
|
||||||
}: AnimeOverviewStatsProps) {
|
|
||||||
const lookupRate = buildLookupRateDisplay(
|
|
||||||
detail.totalYomitanLookupCount,
|
|
||||||
detail.totalWordsSeen,
|
|
||||||
);
|
|
||||||
|
|
||||||
const knownPct =
|
const knownPct =
|
||||||
knownWordsSummary && knownWordsSummary.totalUniqueWords > 0
|
knownWordsSummary && knownWordsSummary.totalUniqueWords > 0
|
||||||
? Math.round(
|
? Math.round((knownWordsSummary.knownWordCount / knownWordsSummary.totalUniqueWords) * 100)
|
||||||
(knownWordsSummary.knownWordCount / knownWordsSummary.totalUniqueWords) * 100,
|
|
||||||
)
|
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -76,10 +68,10 @@ export function AnimeOverviewStats({
|
|||||||
tooltip="Number of completed episodes for this anime"
|
tooltip="Number of completed episodes for this anime"
|
||||||
/>
|
/>
|
||||||
<Metric
|
<Metric
|
||||||
label="Words Seen"
|
label="Tokens Seen"
|
||||||
value={formatNumber(detail.totalWordsSeen)}
|
value={formatNumber(detail.totalTokensSeen)}
|
||||||
color="text-ctp-mauve"
|
color="text-ctp-mauve"
|
||||||
tooltip="Total word occurrences across all sessions"
|
tooltip="Total token occurrences across all sessions"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -88,7 +80,7 @@ export function AnimeOverviewStats({
|
|||||||
<Metric
|
<Metric
|
||||||
label="Cards Mined"
|
label="Cards Mined"
|
||||||
value={formatNumber(detail.totalCards)}
|
value={formatNumber(detail.totalCards)}
|
||||||
color="text-ctp-green"
|
color="text-ctp-cards-mined"
|
||||||
tooltip="Anki cards created from subtitle lines in this anime"
|
tooltip="Anki cards created from subtitle lines in this anime"
|
||||||
/>
|
/>
|
||||||
<Metric
|
<Metric
|
||||||
@@ -102,7 +94,7 @@ export function AnimeOverviewStats({
|
|||||||
label="Lookup Rate"
|
label="Lookup Rate"
|
||||||
value={lookupRate.shortValue}
|
value={lookupRate.shortValue}
|
||||||
color="text-ctp-sapphire"
|
color="text-ctp-sapphire"
|
||||||
tooltip="Yomitan lookups per 100 words seen"
|
tooltip="Yomitan lookups per 100 tokens seen"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Metric
|
<Metric
|
||||||
@@ -124,7 +116,7 @@ export function AnimeOverviewStats({
|
|||||||
label="Known Words"
|
label="Known Words"
|
||||||
value="—"
|
value="—"
|
||||||
color="text-ctp-overlay2"
|
color="text-ctp-overlay2"
|
||||||
tooltip="No word data available yet"
|
tooltip="No token data available yet"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -89,9 +89,9 @@ export function EpisodeDetail({ videoId, onSessionDeleted }: EpisodeDetailProps)
|
|||||||
{s.startedAtMs > 0 ? formatRelativeDate(s.startedAtMs) : '\u2014'}
|
{s.startedAtMs > 0 ? formatRelativeDate(s.startedAtMs) : '\u2014'}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-ctp-blue">{formatDuration(s.activeWatchedMs)}</span>
|
<span className="text-ctp-blue">{formatDuration(s.activeWatchedMs)}</span>
|
||||||
<span className="text-ctp-green">{formatNumber(s.cardsMined)} cards</span>
|
<span className="text-ctp-cards-mined">{formatNumber(s.cardsMined)} cards</span>
|
||||||
<span className="text-ctp-peach">
|
<span className="text-ctp-peach">
|
||||||
{formatNumber(getSessionDisplayWordCount(s))} words
|
{formatNumber(getSessionDisplayWordCount(s))} tokens
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -141,7 +141,7 @@ export function EpisodeDetail({ videoId, onSessionDeleted }: EpisodeDetailProps)
|
|||||||
);
|
);
|
||||||
})
|
})
|
||||||
) : (
|
) : (
|
||||||
<span className="text-ctp-green">
|
<span className="text-ctp-cards-mined">
|
||||||
+{ev.cardsDelta} {ev.cardsDelta === 1 ? 'card' : 'cards'}
|
+{ev.cardsDelta} {ev.cardsDelta === 1 ? 'card' : 'cards'}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ export function EpisodeList({
|
|||||||
{sorted.map((ep, idx) => {
|
{sorted.map((ep, idx) => {
|
||||||
const lookupRate = buildLookupRateDisplay(
|
const lookupRate = buildLookupRateDisplay(
|
||||||
ep.totalYomitanLookupCount,
|
ep.totalYomitanLookupCount,
|
||||||
ep.totalWordsSeen,
|
ep.totalTokensSeen,
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -118,7 +118,7 @@ export function EpisodeList({
|
|||||||
<td className="py-2 pr-3 text-right text-ctp-blue">
|
<td className="py-2 pr-3 text-right text-ctp-blue">
|
||||||
{formatDuration(ep.totalActiveMs)}
|
{formatDuration(ep.totalActiveMs)}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-2 pr-3 text-right text-ctp-green">
|
<td className="py-2 pr-3 text-right text-ctp-cards-mined">
|
||||||
{formatNumber(ep.totalCards)}
|
{formatNumber(ep.totalCards)}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-2 pr-3 text-right">
|
<td className="py-2 pr-3 text-right">
|
||||||
|
|||||||
@@ -41,7 +41,10 @@ export function MediaDetailView({
|
|||||||
totalSessions: sessions.length,
|
totalSessions: sessions.length,
|
||||||
totalActiveMs: sessions.reduce((sum, session) => sum + session.activeWatchedMs, 0),
|
totalActiveMs: sessions.reduce((sum, session) => sum + session.activeWatchedMs, 0),
|
||||||
totalCards: sessions.reduce((sum, session) => sum + session.cardsMined, 0),
|
totalCards: sessions.reduce((sum, session) => sum + session.cardsMined, 0),
|
||||||
totalWordsSeen: sessions.reduce((sum, session) => sum + getSessionDisplayWordCount(session), 0),
|
totalTokensSeen: sessions.reduce(
|
||||||
|
(sum, session) => sum + getSessionDisplayWordCount(session),
|
||||||
|
0,
|
||||||
|
),
|
||||||
totalLinesSeen: sessions.reduce((sum, session) => sum + session.linesSeen, 0),
|
totalLinesSeen: sessions.reduce((sum, session) => sum + session.linesSeen, 0),
|
||||||
totalLookupCount: sessions.reduce((sum, session) => sum + session.lookupCount, 0),
|
totalLookupCount: sessions.reduce((sum, session) => sum + session.lookupCount, 0),
|
||||||
totalLookupHits: sessions.reduce((sum, session) => sum + session.lookupHits, 0),
|
totalLookupHits: sessions.reduce((sum, session) => sum + session.lookupHits, 0),
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export function MediaHeader({ detail, initialKnownWordsSummary = null }: MediaHe
|
|||||||
detail.totalLookupCount > 0 ? detail.totalLookupHits / detail.totalLookupCount : null;
|
detail.totalLookupCount > 0 ? detail.totalLookupHits / detail.totalLookupCount : null;
|
||||||
const avgSessionMs =
|
const avgSessionMs =
|
||||||
detail.totalSessions > 0 ? Math.round(detail.totalActiveMs / detail.totalSessions) : 0;
|
detail.totalSessions > 0 ? Math.round(detail.totalActiveMs / detail.totalSessions) : 0;
|
||||||
const lookupRate = buildLookupRateDisplay(detail.totalYomitanLookupCount, detail.totalWordsSeen);
|
const lookupRate = buildLookupRateDisplay(detail.totalYomitanLookupCount, detail.totalTokensSeen);
|
||||||
|
|
||||||
const [knownWordsSummary, setKnownWordsSummary] = useState<{
|
const [knownWordsSummary, setKnownWordsSummary] = useState<{
|
||||||
totalUniqueWords: number;
|
totalUniqueWords: number;
|
||||||
@@ -55,12 +55,12 @@ export function MediaHeader({ detail, initialKnownWordsSummary = null }: MediaHe
|
|||||||
<div className="text-xs text-ctp-overlay2">total watch time</div>
|
<div className="text-xs text-ctp-overlay2">total watch time</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-ctp-green font-medium">{formatNumber(detail.totalCards)}</div>
|
<div className="text-ctp-cards-mined font-medium">{formatNumber(detail.totalCards)}</div>
|
||||||
<div className="text-xs text-ctp-overlay2">cards mined</div>
|
<div className="text-xs text-ctp-overlay2">cards mined</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-ctp-mauve font-medium">{formatNumber(detail.totalWordsSeen)}</div>
|
<div className="text-ctp-mauve font-medium">{formatNumber(detail.totalTokensSeen)}</div>
|
||||||
<div className="text-xs text-ctp-overlay2">word occurrences</div>
|
<div className="text-xs text-ctp-overlay2">token occurrences</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-ctp-lavender font-medium">
|
<div className="text-ctp-lavender font-medium">
|
||||||
@@ -79,10 +79,15 @@ export function MediaHeader({ detail, initialKnownWordsSummary = null }: MediaHe
|
|||||||
{knownWordsSummary && knownWordsSummary.totalUniqueWords > 0 ? (
|
{knownWordsSummary && knownWordsSummary.totalUniqueWords > 0 ? (
|
||||||
<div>
|
<div>
|
||||||
<div className="text-ctp-green font-medium">
|
<div className="text-ctp-green font-medium">
|
||||||
{formatNumber(knownWordsSummary.knownWordCount)} / {formatNumber(knownWordsSummary.totalUniqueWords)}
|
{formatNumber(knownWordsSummary.knownWordCount)} /{' '}
|
||||||
|
{formatNumber(knownWordsSummary.totalUniqueWords)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-ctp-overlay2">
|
<div className="text-xs text-ctp-overlay2">
|
||||||
known unique words ({Math.round((knownWordsSummary.knownWordCount / knownWordsSummary.totalUniqueWords) * 100)}%)
|
known unique words (
|
||||||
|
{Math.round(
|
||||||
|
(knownWordsSummary.knownWordCount / knownWordsSummary.totalUniqueWords) * 100,
|
||||||
|
)}
|
||||||
|
%)
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -122,6 +122,10 @@ export function OverviewTab({ onNavigateToMediaDetail, onNavigateToSession }: Ov
|
|||||||
const summary = buildOverviewSummary(data);
|
const summary = buildOverviewSummary(data);
|
||||||
const streakData = buildStreakCalendar(calendar);
|
const streakData = buildStreakCalendar(calendar);
|
||||||
const showTrackedCardNote = summary.totalTrackedCards === 0 && summary.activeDays > 0;
|
const showTrackedCardNote = summary.totalTrackedCards === 0 && summary.activeDays > 0;
|
||||||
|
const knownWordPercent =
|
||||||
|
knownWordsSummary && knownWordsSummary.totalUniqueWords > 0
|
||||||
|
? Math.round((knownWordsSummary.knownWordCount / knownWordsSummary.totalUniqueWords) * 100)
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -203,7 +207,7 @@ export function OverviewTab({ onNavigateToMediaDetail, onNavigateToSession }: Ov
|
|||||||
<Tooltip text="Total Anki cards mined from subtitle lines across all sessions">
|
<Tooltip text="Total Anki cards mined from subtitle lines across all sessions">
|
||||||
<div className="rounded-lg bg-ctp-surface1/60 p-3">
|
<div className="rounded-lg bg-ctp-surface1/60 p-3">
|
||||||
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Cards Mined</div>
|
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Cards Mined</div>
|
||||||
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-green">
|
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-cards-mined">
|
||||||
{formatNumber(summary.totalTrackedCards)}
|
{formatNumber(summary.totalTrackedCards)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -216,11 +220,11 @@ export function OverviewTab({ onNavigateToMediaDetail, onNavigateToSession }: Ov
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip text="Total word occurrences encountered in today's sessions">
|
<Tooltip text="Total token occurrences encountered in today's sessions">
|
||||||
<div className="rounded-lg bg-ctp-surface1/60 p-3">
|
<div className="rounded-lg bg-ctp-surface1/60 p-3">
|
||||||
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Words Today</div>
|
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Tokens Today</div>
|
||||||
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-sky">
|
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-sky">
|
||||||
{formatNumber(summary.todayWords)}
|
{formatNumber(summary.todayTokens)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -254,6 +258,9 @@ export function OverviewTab({ onNavigateToMediaDetail, onNavigateToSession }: Ov
|
|||||||
<span className="text-sm text-ctp-overlay2 ml-1">
|
<span className="text-sm text-ctp-overlay2 ml-1">
|
||||||
/ {formatNumber(knownWordsSummary.totalUniqueWords)}
|
/ {formatNumber(knownWordsSummary.totalUniqueWords)}
|
||||||
</span>
|
</span>
|
||||||
|
{knownWordPercent != null ? (
|
||||||
|
<span className="text-sm text-ctp-overlay2 ml-1">({knownWordPercent}%)</span>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|||||||
@@ -162,7 +162,7 @@ function SessionItem({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex gap-4 text-xs text-center shrink-0">
|
<div className="flex gap-4 text-xs text-center shrink-0">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-ctp-green font-medium font-mono tabular-nums">
|
<div className="text-ctp-cards-mined font-medium font-mono tabular-nums">
|
||||||
{formatNumber(session.cardsMined)}
|
{formatNumber(session.cardsMined)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-ctp-overlay2">cards</div>
|
<div className="text-ctp-overlay2">cards</div>
|
||||||
@@ -171,7 +171,7 @@ function SessionItem({
|
|||||||
<div className="text-ctp-mauve font-medium font-mono tabular-nums">
|
<div className="text-ctp-mauve font-medium font-mono tabular-nums">
|
||||||
{formatNumber(displayWordCount)}
|
{formatNumber(displayWordCount)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-ctp-overlay2">words</div>
|
<div className="text-ctp-overlay2">tokens</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
@@ -245,18 +245,18 @@ function AnimeGroupRow({
|
|||||||
{group.sessions.length} sessions · {formatDuration(group.totalActiveMs)} active
|
{group.sessions.length} sessions · {formatDuration(group.totalActiveMs)} active
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-4 text-xs text-center shrink-0">
|
<div className="flex gap-4 text-xs text-center shrink-0">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-ctp-green font-medium font-mono tabular-nums">
|
<div className="text-ctp-cards-mined font-medium font-mono tabular-nums">
|
||||||
{formatNumber(group.totalCards)}
|
{formatNumber(group.totalCards)}
|
||||||
</div>
|
|
||||||
<div className="text-ctp-overlay2">cards</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="text-ctp-overlay2">cards</div>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-ctp-mauve font-medium font-mono tabular-nums">
|
<div className="text-ctp-mauve font-medium font-mono tabular-nums">
|
||||||
{formatNumber(group.totalWords)}
|
{formatNumber(group.totalWords)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-ctp-overlay2">words</div>
|
<div className="text-ctp-overlay2">tokens</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@@ -293,10 +293,7 @@ function AnimeGroupRow({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (navigationTarget.type === 'media-detail') {
|
if (navigationTarget.type === 'media-detail') {
|
||||||
onNavigateToMediaDetail(
|
onNavigateToMediaDetail(navigationTarget.videoId, navigationTarget.sessionId);
|
||||||
navigationTarget.videoId,
|
|
||||||
navigationTarget.sessionId,
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
onNavigateToSession(navigationTarget.sessionId);
|
onNavigateToSession(navigationTarget.sessionId);
|
||||||
@@ -319,7 +316,7 @@ function AnimeGroupRow({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex gap-4 text-xs text-center shrink-0">
|
<div className="flex gap-4 text-xs text-center shrink-0">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-ctp-green font-medium font-mono tabular-nums">
|
<div className="text-ctp-cards-mined font-medium font-mono tabular-nums">
|
||||||
{formatNumber(s.cardsMined)}
|
{formatNumber(s.cardsMined)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-ctp-overlay2">cards</div>
|
<div className="text-ctp-overlay2">cards</div>
|
||||||
@@ -328,7 +325,7 @@ function AnimeGroupRow({
|
|||||||
<div className="text-ctp-mauve font-medium font-mono tabular-nums">
|
<div className="text-ctp-mauve font-medium font-mono tabular-nums">
|
||||||
{formatNumber(getSessionDisplayWordCount(s))}
|
{formatNumber(getSessionDisplayWordCount(s))}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-ctp-overlay2">words</div>
|
<div className="text-ctp-overlay2">tokens</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
AreaChart,
|
AreaChart,
|
||||||
Area,
|
Area,
|
||||||
@@ -12,12 +13,19 @@ import {
|
|||||||
CartesianGrid,
|
CartesianGrid,
|
||||||
} from 'recharts';
|
} from 'recharts';
|
||||||
import { useSessionDetail } from '../../hooks/useSessions';
|
import { useSessionDetail } from '../../hooks/useSessions';
|
||||||
|
import { getStatsClient } from '../../hooks/useStatsApi';
|
||||||
import type { KnownWordsTimelinePoint } from '../../hooks/useSessions';
|
import type { KnownWordsTimelinePoint } from '../../hooks/useSessions';
|
||||||
import { CHART_THEME } from '../../lib/chart-theme';
|
import { CHART_THEME } from '../../lib/chart-theme';
|
||||||
import { buildLookupRateDisplay, getYomitanLookupEvents } from '../../lib/yomitan-lookup';
|
import {
|
||||||
|
buildSessionChartEvents,
|
||||||
|
type SessionChartMarker,
|
||||||
|
type SessionEventNoteInfo,
|
||||||
|
} from '../../lib/session-events';
|
||||||
|
import { buildLookupRateDisplay } from '../../lib/yomitan-lookup';
|
||||||
import { getSessionDisplayWordCount } from '../../lib/session-word-count';
|
import { getSessionDisplayWordCount } from '../../lib/session-word-count';
|
||||||
import { EventType } from '../../types/stats';
|
import { EventType } from '../../types/stats';
|
||||||
import type { SessionEvent, SessionSummary } from '../../types/stats';
|
import type { SessionEvent, SessionSummary } from '../../types/stats';
|
||||||
|
import { SessionEventOverlay } from './SessionEventOverlay';
|
||||||
|
|
||||||
interface SessionDetailProps {
|
interface SessionDetailProps {
|
||||||
session: SessionSummary;
|
session: SessionSummary;
|
||||||
@@ -40,9 +48,7 @@ function formatTime(ms: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Build a lookup: linesSeen → knownWordsSeen */
|
/** Build a lookup: linesSeen → knownWordsSeen */
|
||||||
function buildKnownWordsLookup(
|
function buildKnownWordsLookup(knownWordsTimeline: KnownWordsTimelinePoint[]): Map<number, number> {
|
||||||
knownWordsTimeline: KnownWordsTimelinePoint[],
|
|
||||||
): Map<number, number> {
|
|
||||||
const map = new Map<number, number>();
|
const map = new Map<number, number>();
|
||||||
for (const pt of knownWordsTimeline) {
|
for (const pt of knownWordsTimeline) {
|
||||||
map.set(pt.linesSeen, pt.knownWordsSeen);
|
map.set(pt.linesSeen, pt.knownWordsSeen);
|
||||||
@@ -63,24 +69,17 @@ function lookupKnownWords(map: Map<number, number>, linesSeen: number): number {
|
|||||||
return best > 0 ? map.get(best)! : 0;
|
return best > 0 ? map.get(best)! : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PauseRegion {
|
function extractNoteExpression(note: {
|
||||||
startMs: number;
|
noteId: number;
|
||||||
endMs: number;
|
fields: Record<string, { value: string }>;
|
||||||
}
|
}): SessionEventNoteInfo {
|
||||||
|
const expression =
|
||||||
function buildPauseRegions(events: SessionEvent[]): PauseRegion[] {
|
note.fields?.Expression?.value ??
|
||||||
const regions: PauseRegion[] = [];
|
note.fields?.expression?.value ??
|
||||||
const starts = events.filter((e) => e.eventType === EventType.PAUSE_START);
|
note.fields?.Word?.value ??
|
||||||
const ends = events.filter((e) => e.eventType === EventType.PAUSE_END);
|
note.fields?.word?.value ??
|
||||||
|
'';
|
||||||
for (const start of starts) {
|
return { noteId: note.noteId, expression };
|
||||||
const end = ends.find((e) => e.tsMs > start.tsMs);
|
|
||||||
regions.push({
|
|
||||||
startMs: start.tsMs,
|
|
||||||
endMs: end ? end.tsMs : start.tsMs + 2000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return regions;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RatioChartPoint {
|
interface RatioChartPoint {
|
||||||
@@ -100,7 +99,6 @@ interface FallbackChartPoint {
|
|||||||
type TimelineEntry = {
|
type TimelineEntry = {
|
||||||
sampleMs: number;
|
sampleMs: number;
|
||||||
linesSeen: number;
|
linesSeen: number;
|
||||||
wordsSeen: number;
|
|
||||||
tokensSeen: number;
|
tokensSeen: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -108,19 +106,17 @@ export function SessionDetail({ session }: SessionDetailProps) {
|
|||||||
const { timeline, events, knownWordsTimeline, loading, error } = useSessionDetail(
|
const { timeline, events, knownWordsTimeline, loading, error } = useSessionDetail(
|
||||||
session.sessionId,
|
session.sessionId,
|
||||||
);
|
);
|
||||||
|
const [activeMarkerKey, setActiveMarkerKey] = useState<string | null>(null);
|
||||||
if (loading) return <div className="text-ctp-overlay2 text-xs p-2">Loading timeline...</div>;
|
const [noteInfos, setNoteInfos] = useState<Map<number, SessionEventNoteInfo>>(new Map());
|
||||||
if (error) return <div className="text-ctp-red text-xs p-2">Error: {error}</div>;
|
const [loadingNoteIds, setLoadingNoteIds] = useState<Set<number>>(new Set());
|
||||||
|
const requestedNoteIdsRef = useRef<Set<number>>(new Set());
|
||||||
|
|
||||||
const sorted = [...timeline].reverse();
|
const sorted = [...timeline].reverse();
|
||||||
const knownWordsMap = buildKnownWordsLookup(knownWordsTimeline);
|
const knownWordsMap = buildKnownWordsLookup(knownWordsTimeline);
|
||||||
const hasKnownWords = knownWordsMap.size > 0;
|
const hasKnownWords = knownWordsMap.size > 0;
|
||||||
|
|
||||||
const cardEvents = events.filter((e) => e.eventType === EventType.CARD_MINED);
|
const { cardEvents, seekEvents, yomitanLookupEvents, pauseRegions, markers } =
|
||||||
const seekEvents = events.filter(
|
buildSessionChartEvents(events);
|
||||||
(e) => e.eventType === EventType.SEEK_FORWARD || e.eventType === EventType.SEEK_BACKWARD,
|
|
||||||
);
|
|
||||||
const yomitanLookupEvents = getYomitanLookupEvents(events);
|
|
||||||
const lookupRate = buildLookupRateDisplay(
|
const lookupRate = buildLookupRateDisplay(
|
||||||
session.yomitanLookupCount,
|
session.yomitanLookupCount,
|
||||||
getSessionDisplayWordCount(session),
|
getSessionDisplayWordCount(session),
|
||||||
@@ -128,7 +124,76 @@ export function SessionDetail({ session }: SessionDetailProps) {
|
|||||||
const pauseCount = events.filter((e) => e.eventType === EventType.PAUSE_START).length;
|
const pauseCount = events.filter((e) => e.eventType === EventType.PAUSE_START).length;
|
||||||
const seekCount = seekEvents.length;
|
const seekCount = seekEvents.length;
|
||||||
const cardEventCount = cardEvents.length;
|
const cardEventCount = cardEvents.length;
|
||||||
const pauseRegions = buildPauseRegions(events);
|
const activeMarker = useMemo<SessionChartMarker | null>(
|
||||||
|
() => markers.find((marker) => marker.key === activeMarkerKey) ?? null,
|
||||||
|
[markers, activeMarkerKey],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeMarker || activeMarker.kind !== 'card' || activeMarker.noteIds.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const missingNoteIds = activeMarker.noteIds.filter(
|
||||||
|
(noteId) => !requestedNoteIdsRef.current.has(noteId) && !noteInfos.has(noteId),
|
||||||
|
);
|
||||||
|
if (missingNoteIds.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const noteId of missingNoteIds) {
|
||||||
|
requestedNoteIdsRef.current.add(noteId);
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
setLoadingNoteIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
for (const noteId of missingNoteIds) {
|
||||||
|
next.add(noteId);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
getStatsClient()
|
||||||
|
.ankiNotesInfo(missingNoteIds)
|
||||||
|
.then((notes) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setNoteInfos((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
for (const note of notes) {
|
||||||
|
const info = extractNoteExpression(note);
|
||||||
|
next.set(info.noteId, info);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
console.warn('Failed to fetch session event Anki note info:', err);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setLoadingNoteIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
for (const noteId of missingNoteIds) {
|
||||||
|
next.delete(noteId);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [activeMarker, noteInfos]);
|
||||||
|
|
||||||
|
const handleOpenNote = (noteId: number) => {
|
||||||
|
void getStatsClient().ankiBrowse(noteId);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) return <div className="text-ctp-overlay2 text-xs p-2">Loading timeline...</div>;
|
||||||
|
if (error) return <div className="text-ctp-red text-xs p-2">Error: {error}</div>;
|
||||||
|
|
||||||
if (hasKnownWords) {
|
if (hasKnownWords) {
|
||||||
return (
|
return (
|
||||||
@@ -136,8 +201,15 @@ export function SessionDetail({ session }: SessionDetailProps) {
|
|||||||
sorted={sorted}
|
sorted={sorted}
|
||||||
knownWordsMap={knownWordsMap}
|
knownWordsMap={knownWordsMap}
|
||||||
cardEvents={cardEvents}
|
cardEvents={cardEvents}
|
||||||
|
seekEvents={seekEvents}
|
||||||
yomitanLookupEvents={yomitanLookupEvents}
|
yomitanLookupEvents={yomitanLookupEvents}
|
||||||
pauseRegions={pauseRegions}
|
pauseRegions={pauseRegions}
|
||||||
|
markers={markers}
|
||||||
|
activeMarkerKey={activeMarkerKey}
|
||||||
|
onActiveMarkerChange={setActiveMarkerKey}
|
||||||
|
noteInfos={noteInfos}
|
||||||
|
loadingNoteIds={loadingNoteIds}
|
||||||
|
onOpenNote={handleOpenNote}
|
||||||
pauseCount={pauseCount}
|
pauseCount={pauseCount}
|
||||||
seekCount={seekCount}
|
seekCount={seekCount}
|
||||||
cardEventCount={cardEventCount}
|
cardEventCount={cardEventCount}
|
||||||
@@ -151,8 +223,15 @@ export function SessionDetail({ session }: SessionDetailProps) {
|
|||||||
<FallbackView
|
<FallbackView
|
||||||
sorted={sorted}
|
sorted={sorted}
|
||||||
cardEvents={cardEvents}
|
cardEvents={cardEvents}
|
||||||
|
seekEvents={seekEvents}
|
||||||
yomitanLookupEvents={yomitanLookupEvents}
|
yomitanLookupEvents={yomitanLookupEvents}
|
||||||
pauseRegions={pauseRegions}
|
pauseRegions={pauseRegions}
|
||||||
|
markers={markers}
|
||||||
|
activeMarkerKey={activeMarkerKey}
|
||||||
|
onActiveMarkerChange={setActiveMarkerKey}
|
||||||
|
noteInfos={noteInfos}
|
||||||
|
loadingNoteIds={loadingNoteIds}
|
||||||
|
onOpenNote={handleOpenNote}
|
||||||
pauseCount={pauseCount}
|
pauseCount={pauseCount}
|
||||||
seekCount={seekCount}
|
seekCount={seekCount}
|
||||||
cardEventCount={cardEventCount}
|
cardEventCount={cardEventCount}
|
||||||
@@ -168,8 +247,15 @@ function RatioView({
|
|||||||
sorted,
|
sorted,
|
||||||
knownWordsMap,
|
knownWordsMap,
|
||||||
cardEvents,
|
cardEvents,
|
||||||
|
seekEvents,
|
||||||
yomitanLookupEvents,
|
yomitanLookupEvents,
|
||||||
pauseRegions,
|
pauseRegions,
|
||||||
|
markers,
|
||||||
|
activeMarkerKey,
|
||||||
|
onActiveMarkerChange,
|
||||||
|
noteInfos,
|
||||||
|
loadingNoteIds,
|
||||||
|
onOpenNote,
|
||||||
pauseCount,
|
pauseCount,
|
||||||
seekCount,
|
seekCount,
|
||||||
cardEventCount,
|
cardEventCount,
|
||||||
@@ -179,8 +265,15 @@ function RatioView({
|
|||||||
sorted: TimelineEntry[];
|
sorted: TimelineEntry[];
|
||||||
knownWordsMap: Map<number, number>;
|
knownWordsMap: Map<number, number>;
|
||||||
cardEvents: SessionEvent[];
|
cardEvents: SessionEvent[];
|
||||||
|
seekEvents: SessionEvent[];
|
||||||
yomitanLookupEvents: SessionEvent[];
|
yomitanLookupEvents: SessionEvent[];
|
||||||
pauseRegions: PauseRegion[];
|
pauseRegions: Array<{ startMs: number; endMs: number }>;
|
||||||
|
markers: SessionChartMarker[];
|
||||||
|
activeMarkerKey: string | null;
|
||||||
|
onActiveMarkerChange: (markerKey: string | null) => void;
|
||||||
|
noteInfos: Map<number, SessionEventNoteInfo>;
|
||||||
|
loadingNoteIds: Set<number>;
|
||||||
|
onOpenNote: (noteId: number) => void;
|
||||||
pauseCount: number;
|
pauseCount: number;
|
||||||
seekCount: number;
|
seekCount: number;
|
||||||
cardEventCount: number;
|
cardEventCount: number;
|
||||||
@@ -205,7 +298,7 @@ function RatioView({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (chartData.length === 0) {
|
if (chartData.length === 0) {
|
||||||
return <div className="text-ctp-overlay2 text-xs p-2">No word data for this session.</div>;
|
return <div className="text-ctp-overlay2 text-xs p-2">No token data for this session.</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tsMin = chartData[0]!.tsMs;
|
const tsMin = chartData[0]!.tsMs;
|
||||||
@@ -217,8 +310,9 @@ function RatioView({
|
|||||||
return (
|
return (
|
||||||
<div className="bg-ctp-mantle border border-ctp-surface1 rounded-lg p-3 mt-1 space-y-1">
|
<div className="bg-ctp-mantle border border-ctp-surface1 rounded-lg p-3 mt-1 space-y-1">
|
||||||
{/* ── Top: Percentage area chart ── */}
|
{/* ── Top: Percentage area chart ── */}
|
||||||
<ResponsiveContainer width="100%" height={130}>
|
<div className="relative">
|
||||||
<AreaChart data={chartData}>
|
<ResponsiveContainer width="100%" height={130}>
|
||||||
|
<AreaChart data={chartData}>
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id={`knownGrad-${session.sessionId}`} x1="0" y1="1" x2="0" y2="0">
|
<linearGradient id={`knownGrad-${session.sessionId}`} x1="0" y1="1" x2="0" y2="0">
|
||||||
<stop offset="0%" stopColor="#a6da95" stopOpacity={0.4} />
|
<stop offset="0%" stopColor="#a6da95" stopOpacity={0.4} />
|
||||||
@@ -297,36 +391,45 @@ function RatioView({
|
|||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Card mine markers */}
|
{/* Card mine markers */}
|
||||||
{cardEvents.map((e, i) => (
|
{cardEvents.map((e, i) => (
|
||||||
<ReferenceLine
|
<ReferenceLine
|
||||||
key={`card-${i}`}
|
key={`card-${i}`}
|
||||||
yAxisId="pct"
|
yAxisId="pct"
|
||||||
x={e.tsMs}
|
x={e.tsMs}
|
||||||
stroke="#a6da95"
|
stroke="#a6da95"
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
strokeOpacity={0.8}
|
strokeOpacity={0.8}
|
||||||
label={{
|
/>
|
||||||
value: '\u26CF',
|
))}
|
||||||
position: 'top',
|
|
||||||
fill: '#a6da95',
|
{seekEvents.map((e, i) => {
|
||||||
fontSize: 14,
|
const isBackward = e.eventType === EventType.SEEK_BACKWARD;
|
||||||
fontWeight: 700,
|
const stroke = isBackward ? '#f5bde6' : '#8bd5ca';
|
||||||
}}
|
return (
|
||||||
/>
|
<ReferenceLine
|
||||||
))}
|
key={`seek-${i}`}
|
||||||
|
yAxisId="pct"
|
||||||
|
x={e.tsMs}
|
||||||
|
stroke={stroke}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
strokeOpacity={0.75}
|
||||||
|
strokeDasharray="4 3"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
{/* Yomitan lookup markers */}
|
{/* Yomitan lookup markers */}
|
||||||
{yomitanLookupEvents.map((e, i) => (
|
{yomitanLookupEvents.map((e, i) => (
|
||||||
<ReferenceLine
|
<ReferenceLine
|
||||||
key={`yomitan-${i}`}
|
key={`yomitan-${i}`}
|
||||||
yAxisId="pct"
|
yAxisId="pct"
|
||||||
x={e.tsMs}
|
x={e.tsMs}
|
||||||
stroke="#b7bdf8"
|
stroke="#b7bdf8"
|
||||||
strokeWidth={1.5}
|
strokeWidth={1.5}
|
||||||
strokeDasharray="2 3"
|
strokeDasharray="2 3"
|
||||||
strokeOpacity={0.7}
|
strokeOpacity={0.7}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<Area
|
<Area
|
||||||
yAxisId="pct"
|
yAxisId="pct"
|
||||||
@@ -352,12 +455,23 @@ function RatioView({
|
|||||||
type="monotone"
|
type="monotone"
|
||||||
isAnimationActive={false}
|
isAnimationActive={false}
|
||||||
/>
|
/>
|
||||||
</AreaChart>
|
</AreaChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
|
<SessionEventOverlay
|
||||||
|
markers={markers}
|
||||||
|
tsMin={tsMin}
|
||||||
|
tsMax={tsMax}
|
||||||
|
activeMarkerKey={activeMarkerKey}
|
||||||
|
onActiveMarkerChange={onActiveMarkerChange}
|
||||||
|
noteInfos={noteInfos}
|
||||||
|
loadingNoteIds={loadingNoteIds}
|
||||||
|
onOpenNote={onOpenNote}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* ── Bottom: Word accumulation sparkline ── */}
|
{/* ── Bottom: Token accumulation sparkline ── */}
|
||||||
<div className="flex items-center gap-2 border-t border-ctp-surface1 pt-1">
|
<div className="flex items-center gap-2 border-t border-ctp-surface1 pt-1">
|
||||||
<span className="text-[9px] text-ctp-overlay0 whitespace-nowrap">total words</span>
|
<span className="text-[9px] text-ctp-overlay0 whitespace-nowrap">total tokens</span>
|
||||||
<div className="flex-1 h-[28px]">
|
<div className="flex-1 h-[28px]">
|
||||||
<ResponsiveContainer width="100%" height={28}>
|
<ResponsiveContainer width="100%" height={28}>
|
||||||
<LineChart data={sparkData}>
|
<LineChart data={sparkData}>
|
||||||
@@ -398,8 +512,15 @@ function RatioView({
|
|||||||
function FallbackView({
|
function FallbackView({
|
||||||
sorted,
|
sorted,
|
||||||
cardEvents,
|
cardEvents,
|
||||||
|
seekEvents,
|
||||||
yomitanLookupEvents,
|
yomitanLookupEvents,
|
||||||
pauseRegions,
|
pauseRegions,
|
||||||
|
markers,
|
||||||
|
activeMarkerKey,
|
||||||
|
onActiveMarkerChange,
|
||||||
|
noteInfos,
|
||||||
|
loadingNoteIds,
|
||||||
|
onOpenNote,
|
||||||
pauseCount,
|
pauseCount,
|
||||||
seekCount,
|
seekCount,
|
||||||
cardEventCount,
|
cardEventCount,
|
||||||
@@ -408,8 +529,15 @@ function FallbackView({
|
|||||||
}: {
|
}: {
|
||||||
sorted: TimelineEntry[];
|
sorted: TimelineEntry[];
|
||||||
cardEvents: SessionEvent[];
|
cardEvents: SessionEvent[];
|
||||||
|
seekEvents: SessionEvent[];
|
||||||
yomitanLookupEvents: SessionEvent[];
|
yomitanLookupEvents: SessionEvent[];
|
||||||
pauseRegions: PauseRegion[];
|
pauseRegions: Array<{ startMs: number; endMs: number }>;
|
||||||
|
markers: SessionChartMarker[];
|
||||||
|
activeMarkerKey: string | null;
|
||||||
|
onActiveMarkerChange: (markerKey: string | null) => void;
|
||||||
|
noteInfos: Map<number, SessionEventNoteInfo>;
|
||||||
|
loadingNoteIds: Set<number>;
|
||||||
|
onOpenNote: (noteId: number) => void;
|
||||||
pauseCount: number;
|
pauseCount: number;
|
||||||
seekCount: number;
|
seekCount: number;
|
||||||
cardEventCount: number;
|
cardEventCount: number;
|
||||||
@@ -424,7 +552,7 @@ function FallbackView({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (chartData.length === 0) {
|
if (chartData.length === 0) {
|
||||||
return <div className="text-ctp-overlay2 text-xs p-2">No word data for this session.</div>;
|
return <div className="text-ctp-overlay2 text-xs p-2">No token data for this session.</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tsMin = chartData[0]!.tsMs;
|
const tsMin = chartData[0]!.tsMs;
|
||||||
@@ -432,8 +560,9 @@ function FallbackView({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-ctp-mantle border border-ctp-surface1 rounded-lg p-3 mt-1 space-y-3">
|
<div className="bg-ctp-mantle border border-ctp-surface1 rounded-lg p-3 mt-1 space-y-3">
|
||||||
<ResponsiveContainer width="100%" height={130}>
|
<div className="relative">
|
||||||
<LineChart data={chartData}>
|
<ResponsiveContainer width="100%" height={130}>
|
||||||
|
<LineChart data={chartData}>
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="tsMs"
|
dataKey="tsMs"
|
||||||
type="number"
|
type="number"
|
||||||
@@ -454,7 +583,7 @@ function FallbackView({
|
|||||||
<Tooltip
|
<Tooltip
|
||||||
contentStyle={tooltipStyle}
|
contentStyle={tooltipStyle}
|
||||||
labelFormatter={formatTime}
|
labelFormatter={formatTime}
|
||||||
formatter={(value: number) => [`${value.toLocaleString()}`, 'Total words']}
|
formatter={(value: number) => [`${value.toLocaleString()}`, 'Total tokens']}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{pauseRegions.map((r, i) => (
|
{pauseRegions.map((r, i) => (
|
||||||
@@ -471,32 +600,39 @@ function FallbackView({
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{cardEvents.map((e, i) => (
|
{cardEvents.map((e, i) => (
|
||||||
<ReferenceLine
|
<ReferenceLine
|
||||||
key={`card-${i}`}
|
key={`card-${i}`}
|
||||||
x={e.tsMs}
|
x={e.tsMs}
|
||||||
stroke="#a6da95"
|
stroke="#a6da95"
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
strokeOpacity={0.8}
|
strokeOpacity={0.8}
|
||||||
label={{
|
/>
|
||||||
value: '\u26CF',
|
))}
|
||||||
position: 'top',
|
{seekEvents.map((e, i) => {
|
||||||
fill: '#a6da95',
|
const isBackward = e.eventType === EventType.SEEK_BACKWARD;
|
||||||
fontSize: 14,
|
const stroke = isBackward ? '#f5bde6' : '#8bd5ca';
|
||||||
fontWeight: 700,
|
return (
|
||||||
}}
|
<ReferenceLine
|
||||||
/>
|
key={`seek-${i}`}
|
||||||
))}
|
x={e.tsMs}
|
||||||
{yomitanLookupEvents.map((e, i) => (
|
stroke={stroke}
|
||||||
<ReferenceLine
|
strokeWidth={1.5}
|
||||||
key={`yomitan-${i}`}
|
strokeOpacity={0.75}
|
||||||
x={e.tsMs}
|
strokeDasharray="4 3"
|
||||||
stroke="#b7bdf8"
|
/>
|
||||||
strokeWidth={1.5}
|
);
|
||||||
strokeDasharray="2 3"
|
})}
|
||||||
strokeOpacity={0.7}
|
{yomitanLookupEvents.map((e, i) => (
|
||||||
/>
|
<ReferenceLine
|
||||||
))}
|
key={`yomitan-${i}`}
|
||||||
|
x={e.tsMs}
|
||||||
|
stroke="#b7bdf8"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
strokeDasharray="2 3"
|
||||||
|
strokeOpacity={0.7}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
<Line
|
<Line
|
||||||
dataKey="totalWords"
|
dataKey="totalWords"
|
||||||
@@ -504,12 +640,23 @@ function FallbackView({
|
|||||||
strokeWidth={1.5}
|
strokeWidth={1.5}
|
||||||
dot={false}
|
dot={false}
|
||||||
activeDot={{ r: 3, fill: '#8aadf4', stroke: '#1e2030', strokeWidth: 1 }}
|
activeDot={{ r: 3, fill: '#8aadf4', stroke: '#1e2030', strokeWidth: 1 }}
|
||||||
name="Total words"
|
name="Total tokens"
|
||||||
type="monotone"
|
type="monotone"
|
||||||
isAnimationActive={false}
|
isAnimationActive={false}
|
||||||
/>
|
/>
|
||||||
</LineChart>
|
</LineChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
|
<SessionEventOverlay
|
||||||
|
markers={markers}
|
||||||
|
tsMin={tsMin}
|
||||||
|
tsMax={tsMax}
|
||||||
|
activeMarkerKey={activeMarkerKey}
|
||||||
|
onActiveMarkerChange={onActiveMarkerChange}
|
||||||
|
noteInfos={noteInfos}
|
||||||
|
loadingNoteIds={loadingNoteIds}
|
||||||
|
onOpenNote={onOpenNote}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<StatsBar
|
<StatsBar
|
||||||
hasKnownWords={false}
|
hasKnownWords={false}
|
||||||
@@ -596,7 +743,7 @@ function StatsBar({
|
|||||||
)}
|
)}
|
||||||
<span className="flex items-center gap-1.5">
|
<span className="flex items-center gap-1.5">
|
||||||
<span className="text-[12px]">{'\u26CF'}</span>
|
<span className="text-[12px]">{'\u26CF'}</span>
|
||||||
<span className="text-ctp-green">
|
<span className="text-ctp-cards-mined">
|
||||||
{Math.max(cardEventCount, session.cardsMined)} card
|
{Math.max(cardEventCount, session.cardsMined)} card
|
||||||
{Math.max(cardEventCount, session.cardsMined) !== 1 ? 's' : ''} mined
|
{Math.max(cardEventCount, session.cardsMined) !== 1 ? 's' : ''} mined
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ export function SessionRow({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex gap-4 text-xs text-center shrink-0">
|
<div className="flex gap-4 text-xs text-center shrink-0">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-ctp-green font-medium font-mono tabular-nums">
|
<div className="text-ctp-cards-mined font-medium font-mono tabular-nums">
|
||||||
{formatNumber(session.cardsMined)}
|
{formatNumber(session.cardsMined)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-ctp-overlay2">cards</div>
|
<div className="text-ctp-overlay2">cards</div>
|
||||||
@@ -93,7 +93,7 @@ export function SessionRow({
|
|||||||
<div className="text-ctp-mauve font-medium font-mono tabular-nums">
|
<div className="text-ctp-mauve font-medium font-mono tabular-nums">
|
||||||
{formatNumber(displayWordCount)}
|
{formatNumber(displayWordCount)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-ctp-overlay2">words</div>
|
<div className="text-ctp-overlay2">tokens</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -97,6 +97,17 @@ export function TrendsTab() {
|
|||||||
const [groupBy, setGroupBy] = useState<GroupBy>('day');
|
const [groupBy, setGroupBy] = useState<GroupBy>('day');
|
||||||
const [hiddenAnime, setHiddenAnime] = useState<Set<string>>(() => new Set());
|
const [hiddenAnime, setHiddenAnime] = useState<Set<string>>(() => new Set());
|
||||||
const { data, loading, error } = useTrends(range, groupBy);
|
const { data, loading, error } = useTrends(range, groupBy);
|
||||||
|
const cardsMinedColor = 'var(--color-ctp-cards-mined)';
|
||||||
|
const cardsMinedStackedColors = [
|
||||||
|
cardsMinedColor,
|
||||||
|
'#8aadf4',
|
||||||
|
'#c6a0f6',
|
||||||
|
'#f5a97f',
|
||||||
|
'#f5bde6',
|
||||||
|
'#91d7e3',
|
||||||
|
'#ee99a0',
|
||||||
|
'#f4dbd6',
|
||||||
|
];
|
||||||
|
|
||||||
if (loading) return <div className="text-ctp-overlay2 p-4">Loading...</div>;
|
if (loading) return <div className="text-ctp-overlay2 p-4">Loading...</div>;
|
||||||
if (error) return <div className="text-ctp-red p-4">Error: {error}</div>;
|
if (error) return <div className="text-ctp-red p-4">Error: {error}</div>;
|
||||||
@@ -115,19 +126,40 @@ export function TrendsTab() {
|
|||||||
]);
|
]);
|
||||||
const activeHiddenAnime = pruneHiddenAnime(hiddenAnime, animeTitles);
|
const activeHiddenAnime = pruneHiddenAnime(hiddenAnime, animeTitles);
|
||||||
|
|
||||||
const filteredEpisodesPerAnime = filterHiddenAnimeData(data.animePerDay.episodes, activeHiddenAnime);
|
const filteredEpisodesPerAnime = filterHiddenAnimeData(
|
||||||
const filteredWatchTimePerAnime = filterHiddenAnimeData(data.animePerDay.watchTime, activeHiddenAnime);
|
data.animePerDay.episodes,
|
||||||
|
activeHiddenAnime,
|
||||||
|
);
|
||||||
|
const filteredWatchTimePerAnime = filterHiddenAnimeData(
|
||||||
|
data.animePerDay.watchTime,
|
||||||
|
activeHiddenAnime,
|
||||||
|
);
|
||||||
const filteredCardsPerAnime = filterHiddenAnimeData(data.animePerDay.cards, activeHiddenAnime);
|
const filteredCardsPerAnime = filterHiddenAnimeData(data.animePerDay.cards, activeHiddenAnime);
|
||||||
const filteredWordsPerAnime = filterHiddenAnimeData(data.animePerDay.words, activeHiddenAnime);
|
const filteredWordsPerAnime = filterHiddenAnimeData(data.animePerDay.words, activeHiddenAnime);
|
||||||
const filteredLookupsPerAnime = filterHiddenAnimeData(data.animePerDay.lookups, activeHiddenAnime);
|
const filteredLookupsPerAnime = filterHiddenAnimeData(
|
||||||
|
data.animePerDay.lookups,
|
||||||
|
activeHiddenAnime,
|
||||||
|
);
|
||||||
const filteredLookupsPerHundredPerAnime = filterHiddenAnimeData(
|
const filteredLookupsPerHundredPerAnime = filterHiddenAnimeData(
|
||||||
data.animePerDay.lookupsPerHundred,
|
data.animePerDay.lookupsPerHundred,
|
||||||
activeHiddenAnime,
|
activeHiddenAnime,
|
||||||
);
|
);
|
||||||
const filteredAnimeProgress = filterHiddenAnimeData(data.animeCumulative.episodes, activeHiddenAnime);
|
const filteredAnimeProgress = filterHiddenAnimeData(
|
||||||
const filteredCardsProgress = filterHiddenAnimeData(data.animeCumulative.cards, activeHiddenAnime);
|
data.animeCumulative.episodes,
|
||||||
const filteredWordsProgress = filterHiddenAnimeData(data.animeCumulative.words, activeHiddenAnime);
|
activeHiddenAnime,
|
||||||
const filteredWatchTimeProgress = filterHiddenAnimeData(data.animeCumulative.watchTime, activeHiddenAnime);
|
);
|
||||||
|
const filteredCardsProgress = filterHiddenAnimeData(
|
||||||
|
data.animeCumulative.cards,
|
||||||
|
activeHiddenAnime,
|
||||||
|
);
|
||||||
|
const filteredWordsProgress = filterHiddenAnimeData(
|
||||||
|
data.animeCumulative.words,
|
||||||
|
activeHiddenAnime,
|
||||||
|
);
|
||||||
|
const filteredWatchTimeProgress = filterHiddenAnimeData(
|
||||||
|
data.animeCumulative.watchTime,
|
||||||
|
activeHiddenAnime,
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -145,19 +177,39 @@ export function TrendsTab() {
|
|||||||
color="#8aadf4"
|
color="#8aadf4"
|
||||||
type="bar"
|
type="bar"
|
||||||
/>
|
/>
|
||||||
<TrendChart title="Cards Mined" data={data.activity.cards} color="#a6da95" type="bar" />
|
<TrendChart title="Cards Mined" data={data.activity.cards} color={cardsMinedColor} type="bar" />
|
||||||
<TrendChart title="Words Seen" data={data.activity.words} color="#8bd5ca" type="bar" />
|
<TrendChart title="Tokens Seen" data={data.activity.words} color="#8bd5ca" type="bar" />
|
||||||
<TrendChart title="Sessions" data={data.activity.sessions} color="#b7bdf8" type="bar" />
|
<TrendChart title="Sessions" data={data.activity.sessions} color="#b7bdf8" type="bar" />
|
||||||
|
|
||||||
<SectionHeader>Period Trends</SectionHeader>
|
<SectionHeader>Period Trends</SectionHeader>
|
||||||
<TrendChart title="Watch Time (min)" data={data.progress.watchTime} color="#8aadf4" type="line" />
|
<TrendChart
|
||||||
|
title="Watch Time (min)"
|
||||||
|
data={data.progress.watchTime}
|
||||||
|
color="#8aadf4"
|
||||||
|
type="line"
|
||||||
|
/>
|
||||||
<TrendChart title="Sessions" data={data.progress.sessions} color="#b7bdf8" type="line" />
|
<TrendChart title="Sessions" data={data.progress.sessions} color="#b7bdf8" type="line" />
|
||||||
<TrendChart title="Words Seen" data={data.progress.words} color="#8bd5ca" type="line" />
|
<TrendChart title="Tokens Seen" data={data.progress.words} color="#8bd5ca" type="line" />
|
||||||
<TrendChart title="New Words Seen" data={data.progress.newWords} color="#c6a0f6" type="line" />
|
<TrendChart
|
||||||
<TrendChart title="Cards Mined" data={data.progress.cards} color="#a6da95" type="line" />
|
title="New Words Seen"
|
||||||
<TrendChart title="Episodes Watched" data={data.progress.episodes} color="#91d7e3" type="line" />
|
data={data.progress.newWords}
|
||||||
|
color="#c6a0f6"
|
||||||
|
type="line"
|
||||||
|
/>
|
||||||
|
<TrendChart title="Cards Mined" data={data.progress.cards} color={cardsMinedColor} type="line" />
|
||||||
|
<TrendChart
|
||||||
|
title="Episodes Watched"
|
||||||
|
data={data.progress.episodes}
|
||||||
|
color="#91d7e3"
|
||||||
|
type="line"
|
||||||
|
/>
|
||||||
<TrendChart title="Lookups" data={data.progress.lookups} color="#f5bde6" type="line" />
|
<TrendChart title="Lookups" data={data.progress.lookups} color="#f5bde6" type="line" />
|
||||||
<TrendChart title="Lookups / 100 Words" data={data.ratios.lookupsPerHundred} color="#f5a97f" type="line" />
|
<TrendChart
|
||||||
|
title="Lookups / 100 Tokens"
|
||||||
|
data={data.ratios.lookupsPerHundred}
|
||||||
|
color="#f5a97f"
|
||||||
|
type="line"
|
||||||
|
/>
|
||||||
|
|
||||||
<SectionHeader>Anime — Per Day</SectionHeader>
|
<SectionHeader>Anime — Per Day</SectionHeader>
|
||||||
<AnimeVisibilityFilter
|
<AnimeVisibilityFilter
|
||||||
@@ -179,16 +231,27 @@ export function TrendsTab() {
|
|||||||
/>
|
/>
|
||||||
<StackedTrendChart title="Episodes per Anime" data={filteredEpisodesPerAnime} />
|
<StackedTrendChart title="Episodes per Anime" data={filteredEpisodesPerAnime} />
|
||||||
<StackedTrendChart title="Watch Time per Anime (min)" data={filteredWatchTimePerAnime} />
|
<StackedTrendChart title="Watch Time per Anime (min)" data={filteredWatchTimePerAnime} />
|
||||||
<StackedTrendChart title="Cards Mined per Anime" data={filteredCardsPerAnime} />
|
<StackedTrendChart
|
||||||
<StackedTrendChart title="Words Seen per Anime" data={filteredWordsPerAnime} />
|
title="Cards Mined per Anime"
|
||||||
|
data={filteredCardsPerAnime}
|
||||||
|
colorPalette={cardsMinedStackedColors}
|
||||||
|
/>
|
||||||
|
<StackedTrendChart title="Tokens Seen per Anime" data={filteredWordsPerAnime} />
|
||||||
<StackedTrendChart title="Lookups per Anime" data={filteredLookupsPerAnime} />
|
<StackedTrendChart title="Lookups per Anime" data={filteredLookupsPerAnime} />
|
||||||
<StackedTrendChart title="Lookups/100w per Anime" data={filteredLookupsPerHundredPerAnime} />
|
<StackedTrendChart
|
||||||
|
title="Lookups/100w per Anime"
|
||||||
|
data={filteredLookupsPerHundredPerAnime}
|
||||||
|
/>
|
||||||
|
|
||||||
<SectionHeader>Anime — Cumulative</SectionHeader>
|
<SectionHeader>Anime — Cumulative</SectionHeader>
|
||||||
<StackedTrendChart title="Watch Time Progress (min)" data={filteredWatchTimeProgress} />
|
<StackedTrendChart title="Watch Time Progress (min)" data={filteredWatchTimeProgress} />
|
||||||
<StackedTrendChart title="Episodes Progress" data={filteredAnimeProgress} />
|
<StackedTrendChart title="Episodes Progress" data={filteredAnimeProgress} />
|
||||||
<StackedTrendChart title="Cards Mined Progress" data={filteredCardsProgress} />
|
<StackedTrendChart
|
||||||
<StackedTrendChart title="Words Seen Progress" data={filteredWordsProgress} />
|
title="Cards Mined Progress"
|
||||||
|
data={filteredCardsProgress}
|
||||||
|
colorPalette={cardsMinedStackedColors}
|
||||||
|
/>
|
||||||
|
<StackedTrendChart title="Tokens Seen Progress" data={filteredWordsProgress} />
|
||||||
|
|
||||||
<SectionHeader>Patterns</SectionHeader>
|
<SectionHeader>Patterns</SectionHeader>
|
||||||
<TrendChart
|
<TrendChart
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ test('buildOverviewSummary aggregates tracked totals and recent windows', () =>
|
|||||||
totalWatchedMs: 3_600_000,
|
totalWatchedMs: 3_600_000,
|
||||||
activeWatchedMs: 3_000_000,
|
activeWatchedMs: 3_000_000,
|
||||||
linesSeen: 20,
|
linesSeen: 20,
|
||||||
wordsSeen: 100,
|
|
||||||
tokensSeen: 80,
|
tokensSeen: 80,
|
||||||
cardsMined: 2,
|
cardsMined: 2,
|
||||||
lookupCount: 10,
|
lookupCount: 10,
|
||||||
@@ -45,33 +44,32 @@ test('buildOverviewSummary aggregates tracked totals and recent windows', () =>
|
|||||||
totalSessions: 1,
|
totalSessions: 1,
|
||||||
totalActiveMin: 50,
|
totalActiveMin: 50,
|
||||||
totalLinesSeen: 20,
|
totalLinesSeen: 20,
|
||||||
totalWordsSeen: 100,
|
|
||||||
totalTokensSeen: 80,
|
totalTokensSeen: 80,
|
||||||
totalCards: 2,
|
totalCards: 2,
|
||||||
cardsPerHour: 2.4,
|
cardsPerHour: 2.4,
|
||||||
wordsPerMin: 2,
|
tokensPerMin: 2,
|
||||||
lookupHitRate: 0.8,
|
lookupHitRate: 0.8,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
const overview: OverviewData = {
|
const overview: OverviewData = {
|
||||||
sessions,
|
sessions,
|
||||||
rollups,
|
rollups,
|
||||||
hints: {
|
hints: {
|
||||||
totalSessions: 15,
|
totalSessions: 15,
|
||||||
activeSessions: 0,
|
activeSessions: 0,
|
||||||
episodesToday: 2,
|
episodesToday: 2,
|
||||||
activeAnimeCount: 3,
|
activeAnimeCount: 3,
|
||||||
totalEpisodesWatched: 5,
|
totalEpisodesWatched: 5,
|
||||||
totalAnimeCompleted: 1,
|
totalAnimeCompleted: 1,
|
||||||
totalActiveMin: 50,
|
totalActiveMin: 50,
|
||||||
activeDays: 2,
|
activeDays: 2,
|
||||||
totalCards: 9,
|
totalCards: 9,
|
||||||
totalLookupCount: 100,
|
totalLookupCount: 100,
|
||||||
totalLookupHits: 80,
|
totalLookupHits: 80,
|
||||||
newWordsToday: 5,
|
newWordsToday: 5,
|
||||||
newWordsThisWeek: 20,
|
newWordsThisWeek: 20,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const summary = buildOverviewSummary(overview, now);
|
const summary = buildOverviewSummary(overview, now);
|
||||||
assert.equal(summary.todayCards, 2);
|
assert.equal(summary.todayCards, 2);
|
||||||
@@ -88,8 +86,8 @@ test('buildOverviewSummary aggregates tracked totals and recent windows', () =>
|
|||||||
test('buildOverviewSummary prefers lifetime totals from hints when provided', () => {
|
test('buildOverviewSummary prefers lifetime totals from hints when provided', () => {
|
||||||
const now = Date.UTC(2026, 2, 13, 12);
|
const now = Date.UTC(2026, 2, 13, 12);
|
||||||
const today = Math.floor(now / 86_400_000);
|
const today = Math.floor(now / 86_400_000);
|
||||||
const overview: OverviewData = {
|
const overview: OverviewData = {
|
||||||
sessions: [
|
sessions: [
|
||||||
{
|
{
|
||||||
sessionId: 2,
|
sessionId: 2,
|
||||||
canonicalTitle: 'B',
|
canonicalTitle: 'B',
|
||||||
@@ -101,7 +99,6 @@ test('buildOverviewSummary prefers lifetime totals from hints when provided', ()
|
|||||||
totalWatchedMs: 60_000,
|
totalWatchedMs: 60_000,
|
||||||
activeWatchedMs: 60_000,
|
activeWatchedMs: 60_000,
|
||||||
linesSeen: 10,
|
linesSeen: 10,
|
||||||
wordsSeen: 10,
|
|
||||||
tokensSeen: 10,
|
tokensSeen: 10,
|
||||||
cardsMined: 10,
|
cardsMined: 10,
|
||||||
lookupCount: 1,
|
lookupCount: 1,
|
||||||
@@ -116,11 +113,10 @@ test('buildOverviewSummary prefers lifetime totals from hints when provided', ()
|
|||||||
totalSessions: 1,
|
totalSessions: 1,
|
||||||
totalActiveMin: 1,
|
totalActiveMin: 1,
|
||||||
totalLinesSeen: 10,
|
totalLinesSeen: 10,
|
||||||
totalWordsSeen: 10,
|
|
||||||
totalTokensSeen: 10,
|
totalTokensSeen: 10,
|
||||||
totalCards: 10,
|
totalCards: 10,
|
||||||
cardsPerHour: 600,
|
cardsPerHour: 600,
|
||||||
wordsPerMin: 10,
|
tokensPerMin: 10,
|
||||||
lookupHitRate: 1,
|
lookupHitRate: 1,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -182,11 +178,10 @@ test('buildTrendDashboard derives dense chart series', () => {
|
|||||||
totalSessions: 2,
|
totalSessions: 2,
|
||||||
totalActiveMin: 60,
|
totalActiveMin: 60,
|
||||||
totalLinesSeen: 30,
|
totalLinesSeen: 30,
|
||||||
totalWordsSeen: 120,
|
|
||||||
totalTokensSeen: 100,
|
totalTokensSeen: 100,
|
||||||
totalCards: 3,
|
totalCards: 3,
|
||||||
cardsPerHour: 3,
|
cardsPerHour: 3,
|
||||||
wordsPerMin: 2,
|
tokensPerMin: 2,
|
||||||
lookupHitRate: 0.5,
|
lookupHitRate: 0.5,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -195,18 +190,17 @@ test('buildTrendDashboard derives dense chart series', () => {
|
|||||||
totalSessions: 1,
|
totalSessions: 1,
|
||||||
totalActiveMin: 30,
|
totalActiveMin: 30,
|
||||||
totalLinesSeen: 10,
|
totalLinesSeen: 10,
|
||||||
totalWordsSeen: 40,
|
|
||||||
totalTokensSeen: 30,
|
totalTokensSeen: 30,
|
||||||
totalCards: 1,
|
totalCards: 1,
|
||||||
cardsPerHour: 2,
|
cardsPerHour: 2,
|
||||||
wordsPerMin: 1.33,
|
tokensPerMin: 1.33,
|
||||||
lookupHitRate: 0.75,
|
lookupHitRate: 0.75,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const dashboard = buildTrendDashboard(rollups);
|
const dashboard = buildTrendDashboard(rollups);
|
||||||
assert.equal(dashboard.watchTime.length, 2);
|
assert.equal(dashboard.watchTime.length, 2);
|
||||||
assert.equal(dashboard.words[1]?.value, 40);
|
assert.equal(dashboard.words[1]?.value, 30);
|
||||||
assert.equal(dashboard.sessions[0]?.value, 2);
|
assert.equal(dashboard.sessions[0]?.value, 2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export interface OverviewSummary {
|
|||||||
activeDays: number;
|
activeDays: number;
|
||||||
totalSessions: number;
|
totalSessions: number;
|
||||||
lookupRate: number | null;
|
lookupRate: number | null;
|
||||||
todayWords: number;
|
todayTokens: number;
|
||||||
newWordsToday: number;
|
newWordsToday: number;
|
||||||
newWordsThisWeek: number;
|
newWordsThisWeek: number;
|
||||||
recentWatchTime: ChartPoint[];
|
recentWatchTime: ChartPoint[];
|
||||||
@@ -100,7 +100,7 @@ function buildAggregatedDailyRows(rollups: DailyRollup[]) {
|
|||||||
|
|
||||||
existing.activeMin += rollup.totalActiveMin;
|
existing.activeMin += rollup.totalActiveMin;
|
||||||
existing.cards += rollup.totalCards;
|
existing.cards += rollup.totalCards;
|
||||||
existing.words += rollup.totalWordsSeen;
|
existing.words += rollup.totalTokensSeen;
|
||||||
existing.sessions += rollup.totalSessions;
|
existing.sessions += rollup.totalSessions;
|
||||||
if (rollup.lookupHitRate != null) {
|
if (rollup.lookupHitRate != null) {
|
||||||
const weight = Math.max(rollup.totalSessions, 1);
|
const weight = Math.max(rollup.totalSessions, 1);
|
||||||
@@ -185,9 +185,9 @@ export function buildOverviewSummary(
|
|||||||
overview.hints.totalLookupCount > 0
|
overview.hints.totalLookupCount > 0
|
||||||
? Math.round((overview.hints.totalLookupHits / overview.hints.totalLookupCount) * 100)
|
? Math.round((overview.hints.totalLookupHits / overview.hints.totalLookupCount) * 100)
|
||||||
: null,
|
: null,
|
||||||
todayWords: Math.max(
|
todayTokens: Math.max(
|
||||||
todayRow?.words ?? 0,
|
todayRow?.words ?? 0,
|
||||||
sumBy(todaySessions, (session) => session.wordsSeen),
|
sumBy(todaySessions, (session) => session.tokensSeen),
|
||||||
),
|
),
|
||||||
newWordsToday: overview.hints.newWordsToday ?? 0,
|
newWordsToday: overview.hints.newWordsToday ?? 0,
|
||||||
newWordsThisWeek: overview.hints.newWordsThisWeek ?? 0,
|
newWordsThisWeek: overview.hints.newWordsThisWeek ?? 0,
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ test('MediaSessionList renders expandable session rows with delete affordance',
|
|||||||
totalWatchedMs: 1_000,
|
totalWatchedMs: 1_000,
|
||||||
activeWatchedMs: 900,
|
activeWatchedMs: 900,
|
||||||
linesSeen: 12,
|
linesSeen: 12,
|
||||||
wordsSeen: 24,
|
|
||||||
tokensSeen: 24,
|
tokensSeen: 24,
|
||||||
cardsMined: 2,
|
cardsMined: 2,
|
||||||
lookupCount: 3,
|
lookupCount: 3,
|
||||||
@@ -34,6 +33,6 @@ test('MediaSessionList renders expandable session rows with delete affordance',
|
|||||||
assert.match(markup, /Session History/);
|
assert.match(markup, /Session History/);
|
||||||
assert.match(markup, /aria-expanded="true"/);
|
assert.match(markup, /aria-expanded="true"/);
|
||||||
assert.match(markup, /Delete session Episode 7/);
|
assert.match(markup, /Delete session Episode 7/);
|
||||||
assert.match(markup, /Total words/);
|
assert.match(markup, /tokens/);
|
||||||
assert.match(markup, /1 Yomitan lookup/);
|
assert.match(markup, /No token data for this session/);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import assert from 'node:assert/strict';
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import { renderToStaticMarkup } from 'react-dom/server';
|
import { renderToStaticMarkup } from 'react-dom/server';
|
||||||
import { SessionDetail } from '../components/sessions/SessionDetail';
|
import { SessionDetail } from '../components/sessions/SessionDetail';
|
||||||
|
import { buildSessionChartEvents } from './session-events';
|
||||||
|
import { EventType } from '../types/stats';
|
||||||
|
|
||||||
test('SessionDetail omits the misleading new words metric', () => {
|
test('SessionDetail omits the misleading new words metric', () => {
|
||||||
const markup = renderToStaticMarkup(
|
const markup = renderToStaticMarkup(
|
||||||
@@ -17,7 +19,6 @@ test('SessionDetail omits the misleading new words metric', () => {
|
|||||||
totalWatchedMs: 0,
|
totalWatchedMs: 0,
|
||||||
activeWatchedMs: 0,
|
activeWatchedMs: 0,
|
||||||
linesSeen: 12,
|
linesSeen: 12,
|
||||||
wordsSeen: 24,
|
|
||||||
tokensSeen: 24,
|
tokensSeen: 24,
|
||||||
cardsMined: 0,
|
cardsMined: 0,
|
||||||
lookupCount: 0,
|
lookupCount: 0,
|
||||||
@@ -27,6 +28,33 @@ test('SessionDetail omits the misleading new words metric', () => {
|
|||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.match(markup, /Total words/);
|
assert.match(markup, /No token data/);
|
||||||
assert.doesNotMatch(markup, /New words/);
|
assert.doesNotMatch(markup, /New words/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('buildSessionChartEvents keeps only chart-relevant events and pairs pause ranges', () => {
|
||||||
|
const chartEvents = buildSessionChartEvents([
|
||||||
|
{ eventType: EventType.SUBTITLE_LINE, tsMs: 1_000, payload: '{"line":"ignored"}' },
|
||||||
|
{ eventType: EventType.PAUSE_START, tsMs: 2_000, payload: null },
|
||||||
|
{ eventType: EventType.SEEK_FORWARD, tsMs: 3_000, payload: null },
|
||||||
|
{ eventType: EventType.PAUSE_END, tsMs: 4_000, payload: null },
|
||||||
|
{ eventType: EventType.CARD_MINED, tsMs: 5_000, payload: '{"cardsMined":1}' },
|
||||||
|
{ eventType: EventType.YOMITAN_LOOKUP, tsMs: 6_000, payload: null },
|
||||||
|
{ eventType: EventType.SEEK_BACKWARD, tsMs: 7_000, payload: null },
|
||||||
|
{ eventType: EventType.LOOKUP, tsMs: 8_000, payload: '{"hit":true}' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
chartEvents.seekEvents.map((event) => event.eventType),
|
||||||
|
[EventType.SEEK_FORWARD, EventType.SEEK_BACKWARD],
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
chartEvents.cardEvents.map((event) => event.tsMs),
|
||||||
|
[5_000],
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
chartEvents.yomitanLookupEvents.map((event) => event.tsMs),
|
||||||
|
[6_000],
|
||||||
|
);
|
||||||
|
assert.deepEqual(chartEvents.pauseRegions, [{ startMs: 2_000, endMs: 4_000 }]);
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
type SessionWordCountLike = {
|
type SessionWordCountLike = {
|
||||||
wordsSeen: number;
|
|
||||||
tokensSeen: number;
|
tokensSeen: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getSessionDisplayWordCount(value: SessionWordCountLike): number {
|
export function getSessionDisplayWordCount(value: SessionWordCountLike): number {
|
||||||
return value.tokensSeen > 0 ? value.tokensSeen : value.wordsSeen;
|
return value.tokensSeen;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ test('EpisodeList renders explicit episode detail button alongside quick peek ro
|
|||||||
totalSessions: 1,
|
totalSessions: 1,
|
||||||
totalActiveMs: 1,
|
totalActiveMs: 1,
|
||||||
totalCards: 1,
|
totalCards: 1,
|
||||||
totalWordsSeen: 350,
|
totalTokensSeen: 350,
|
||||||
totalYomitanLookupCount: 7,
|
totalYomitanLookupCount: 7,
|
||||||
lastWatchedMs: 0,
|
lastWatchedMs: 0,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -8,10 +8,10 @@ import { SessionRow } from '../components/sessions/SessionRow';
|
|||||||
import { EventType, type SessionEvent } from '../types/stats';
|
import { EventType, type SessionEvent } from '../types/stats';
|
||||||
import { buildLookupRateDisplay, getYomitanLookupEvents } from './yomitan-lookup';
|
import { buildLookupRateDisplay, getYomitanLookupEvents } from './yomitan-lookup';
|
||||||
|
|
||||||
test('buildLookupRateDisplay formats lookups per 100 words in short and long forms', () => {
|
test('buildLookupRateDisplay formats lookups per 100 tokens in short and long forms', () => {
|
||||||
assert.deepEqual(buildLookupRateDisplay(23, 1000), {
|
assert.deepEqual(buildLookupRateDisplay(23, 1000), {
|
||||||
shortValue: '2.3 / 100 words',
|
shortValue: '2.3 / 100 tokens',
|
||||||
longValue: '2.3 lookups per 100 words',
|
longValue: '2.3 lookups per 100 tokens',
|
||||||
});
|
});
|
||||||
assert.equal(buildLookupRateDisplay(0, 0), null);
|
assert.equal(buildLookupRateDisplay(0, 0), null);
|
||||||
});
|
});
|
||||||
@@ -38,7 +38,7 @@ test('MediaHeader renders Yomitan lookup count and lookup rate copy', () => {
|
|||||||
totalSessions: 4,
|
totalSessions: 4,
|
||||||
totalActiveMs: 90_000,
|
totalActiveMs: 90_000,
|
||||||
totalCards: 12,
|
totalCards: 12,
|
||||||
totalWordsSeen: 1000,
|
totalTokensSeen: 1000,
|
||||||
totalLinesSeen: 120,
|
totalLinesSeen: 120,
|
||||||
totalLookupCount: 30,
|
totalLookupCount: 30,
|
||||||
totalLookupHits: 21,
|
totalLookupHits: 21,
|
||||||
@@ -48,11 +48,11 @@ test('MediaHeader renders Yomitan lookup count and lookup rate copy', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
assert.match(markup, /23/);
|
assert.match(markup, /23/);
|
||||||
assert.match(markup, /2\.3 \/ 100 words/);
|
assert.match(markup, /2\.3 \/ 100 tokens/);
|
||||||
assert.match(markup, /2\.3 lookups per 100 words/);
|
assert.match(markup, /2\.3 lookups per 100 tokens/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('MediaHeader distinguishes word occurrences from known unique words', () => {
|
test('MediaHeader distinguishes token occurrences from known unique words', () => {
|
||||||
const markup = renderToStaticMarkup(
|
const markup = renderToStaticMarkup(
|
||||||
<MediaHeader
|
<MediaHeader
|
||||||
detail={{
|
detail={{
|
||||||
@@ -61,7 +61,7 @@ test('MediaHeader distinguishes word occurrences from known unique words', () =>
|
|||||||
totalSessions: 4,
|
totalSessions: 4,
|
||||||
totalActiveMs: 90_000,
|
totalActiveMs: 90_000,
|
||||||
totalCards: 12,
|
totalCards: 12,
|
||||||
totalWordsSeen: 30,
|
totalTokensSeen: 30,
|
||||||
totalLinesSeen: 120,
|
totalLinesSeen: 120,
|
||||||
totalLookupCount: 30,
|
totalLookupCount: 30,
|
||||||
totalLookupHits: 21,
|
totalLookupHits: 21,
|
||||||
@@ -74,7 +74,7 @@ test('MediaHeader distinguishes word occurrences from known unique words', () =>
|
|||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.match(markup, /word occurrences/);
|
assert.match(markup, /token occurrences/);
|
||||||
assert.match(markup, /known unique words \(50%\)/);
|
assert.match(markup, /known unique words \(50%\)/);
|
||||||
assert.match(markup, /17 \/ 34/);
|
assert.match(markup, /17 \/ 34/);
|
||||||
});
|
});
|
||||||
@@ -93,7 +93,7 @@ test('EpisodeList renders per-episode Yomitan lookup rate', () => {
|
|||||||
totalSessions: 1,
|
totalSessions: 1,
|
||||||
totalActiveMs: 1,
|
totalActiveMs: 1,
|
||||||
totalCards: 1,
|
totalCards: 1,
|
||||||
totalWordsSeen: 350,
|
totalTokensSeen: 350,
|
||||||
totalYomitanLookupCount: 7,
|
totalYomitanLookupCount: 7,
|
||||||
lastWatchedMs: 0,
|
lastWatchedMs: 0,
|
||||||
},
|
},
|
||||||
@@ -102,7 +102,7 @@ test('EpisodeList renders per-episode Yomitan lookup rate', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
assert.match(markup, /Lookup Rate/);
|
assert.match(markup, /Lookup Rate/);
|
||||||
assert.match(markup, /2\.0 \/ 100 words/);
|
assert.match(markup, /2\.0 \/ 100 tokens/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('AnimeOverviewStats renders aggregate Yomitan lookup metrics', () => {
|
test('AnimeOverviewStats renders aggregate Yomitan lookup metrics', () => {
|
||||||
@@ -119,7 +119,7 @@ test('AnimeOverviewStats renders aggregate Yomitan lookup metrics', () => {
|
|||||||
totalSessions: 5,
|
totalSessions: 5,
|
||||||
totalActiveMs: 100_000,
|
totalActiveMs: 100_000,
|
||||||
totalCards: 8,
|
totalCards: 8,
|
||||||
totalWordsSeen: 800,
|
totalTokensSeen: 800,
|
||||||
totalLinesSeen: 100,
|
totalLinesSeen: 100,
|
||||||
totalLookupCount: 50,
|
totalLookupCount: 50,
|
||||||
totalLookupHits: 30,
|
totalLookupHits: 30,
|
||||||
@@ -134,8 +134,8 @@ test('AnimeOverviewStats renders aggregate Yomitan lookup metrics', () => {
|
|||||||
|
|
||||||
assert.match(markup, /Lookups/);
|
assert.match(markup, /Lookups/);
|
||||||
assert.match(markup, /16/);
|
assert.match(markup, /16/);
|
||||||
assert.match(markup, /2\.0 \/ 100 words/);
|
assert.match(markup, /2\.0 \/ 100 tokens/);
|
||||||
assert.match(markup, /2\.0 lookups per 100 words/);
|
assert.match(markup, /Yomitan lookups per 100 tokens seen/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('SessionRow prefers token-based word count when available', () => {
|
test('SessionRow prefers token-based word count when available', () => {
|
||||||
@@ -152,7 +152,6 @@ test('SessionRow prefers token-based word count when available', () => {
|
|||||||
totalWatchedMs: 0,
|
totalWatchedMs: 0,
|
||||||
activeWatchedMs: 0,
|
activeWatchedMs: 0,
|
||||||
linesSeen: 12,
|
linesSeen: 12,
|
||||||
wordsSeen: 12,
|
|
||||||
tokensSeen: 42,
|
tokensSeen: 42,
|
||||||
cardsMined: 0,
|
cardsMined: 0,
|
||||||
lookupCount: 0,
|
lookupCount: 0,
|
||||||
|
|||||||
@@ -8,15 +8,15 @@ export interface LookupRateDisplay {
|
|||||||
|
|
||||||
export function buildLookupRateDisplay(
|
export function buildLookupRateDisplay(
|
||||||
yomitanLookupCount: number,
|
yomitanLookupCount: number,
|
||||||
wordsSeen: number,
|
tokensSeen: number,
|
||||||
): LookupRateDisplay | null {
|
): LookupRateDisplay | null {
|
||||||
if (!Number.isFinite(yomitanLookupCount) || !Number.isFinite(wordsSeen) || wordsSeen <= 0) {
|
if (!Number.isFinite(yomitanLookupCount) || !Number.isFinite(tokensSeen) || tokensSeen <= 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const per100 = ((Math.max(0, yomitanLookupCount) / wordsSeen) * 100).toFixed(1);
|
const per100 = ((Math.max(0, yomitanLookupCount) / tokensSeen) * 100).toFixed(1);
|
||||||
return {
|
return {
|
||||||
shortValue: `${per100} / 100 words`,
|
shortValue: `${per100} / 100 tokens`,
|
||||||
longValue: `${per100} lookups per 100 words`,
|
longValue: `${per100} lookups per 100 tokens`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ export interface SessionSummary {
|
|||||||
totalWatchedMs: number;
|
totalWatchedMs: number;
|
||||||
activeWatchedMs: number;
|
activeWatchedMs: number;
|
||||||
linesSeen: number;
|
linesSeen: number;
|
||||||
wordsSeen: number;
|
|
||||||
tokensSeen: number;
|
tokensSeen: number;
|
||||||
cardsMined: number;
|
cardsMined: number;
|
||||||
lookupCount: number;
|
lookupCount: number;
|
||||||
@@ -23,11 +22,10 @@ export interface DailyRollup {
|
|||||||
totalSessions: number;
|
totalSessions: number;
|
||||||
totalActiveMin: number;
|
totalActiveMin: number;
|
||||||
totalLinesSeen: number;
|
totalLinesSeen: number;
|
||||||
totalWordsSeen: number;
|
|
||||||
totalTokensSeen: number;
|
totalTokensSeen: number;
|
||||||
totalCards: number;
|
totalCards: number;
|
||||||
cardsPerHour: number | null;
|
cardsPerHour: number | null;
|
||||||
wordsPerMin: number | null;
|
tokensPerMin: number | null;
|
||||||
lookupHitRate: number | null;
|
lookupHitRate: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,7 +36,6 @@ export interface SessionTimelinePoint {
|
|||||||
totalWatchedMs: number;
|
totalWatchedMs: number;
|
||||||
activeWatchedMs: number;
|
activeWatchedMs: number;
|
||||||
linesSeen: number;
|
linesSeen: number;
|
||||||
wordsSeen: number;
|
|
||||||
tokensSeen: number;
|
tokensSeen: number;
|
||||||
cardsMined: number;
|
cardsMined: number;
|
||||||
}
|
}
|
||||||
@@ -114,7 +111,7 @@ export interface MediaLibraryItem {
|
|||||||
totalSessions: number;
|
totalSessions: number;
|
||||||
totalActiveMs: number;
|
totalActiveMs: number;
|
||||||
totalCards: number;
|
totalCards: number;
|
||||||
totalWordsSeen: number;
|
totalTokensSeen: number;
|
||||||
lastWatchedMs: number;
|
lastWatchedMs: number;
|
||||||
hasCoverArt: number;
|
hasCoverArt: number;
|
||||||
}
|
}
|
||||||
@@ -126,7 +123,7 @@ export interface MediaDetailData {
|
|||||||
totalSessions: number;
|
totalSessions: number;
|
||||||
totalActiveMs: number;
|
totalActiveMs: number;
|
||||||
totalCards: number;
|
totalCards: number;
|
||||||
totalWordsSeen: number;
|
totalTokensSeen: number;
|
||||||
totalLinesSeen: number;
|
totalLinesSeen: number;
|
||||||
totalLookupCount: number;
|
totalLookupCount: number;
|
||||||
totalLookupHits: number;
|
totalLookupHits: number;
|
||||||
@@ -157,7 +154,7 @@ export interface AnimeLibraryItem {
|
|||||||
totalSessions: number;
|
totalSessions: number;
|
||||||
totalActiveMs: number;
|
totalActiveMs: number;
|
||||||
totalCards: number;
|
totalCards: number;
|
||||||
totalWordsSeen: number;
|
totalTokensSeen: number;
|
||||||
episodeCount: number;
|
episodeCount: number;
|
||||||
episodesTotal: number | null;
|
episodesTotal: number | null;
|
||||||
lastWatchedMs: number;
|
lastWatchedMs: number;
|
||||||
@@ -182,7 +179,7 @@ export interface AnimeDetailData {
|
|||||||
totalSessions: number;
|
totalSessions: number;
|
||||||
totalActiveMs: number;
|
totalActiveMs: number;
|
||||||
totalCards: number;
|
totalCards: number;
|
||||||
totalWordsSeen: number;
|
totalTokensSeen: number;
|
||||||
totalLinesSeen: number;
|
totalLinesSeen: number;
|
||||||
totalLookupCount: number;
|
totalLookupCount: number;
|
||||||
totalLookupHits: number;
|
totalLookupHits: number;
|
||||||
@@ -204,7 +201,7 @@ export interface AnimeEpisode {
|
|||||||
totalSessions: number;
|
totalSessions: number;
|
||||||
totalActiveMs: number;
|
totalActiveMs: number;
|
||||||
totalCards: number;
|
totalCards: number;
|
||||||
totalWordsSeen: number;
|
totalTokensSeen: number;
|
||||||
totalYomitanLookupCount: number;
|
totalYomitanLookupCount: number;
|
||||||
lastWatchedMs: number;
|
lastWatchedMs: number;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user