+ +{marker.cardsDelta} {marker.cardsDelta === 1 ? 'card' : 'cards'}
-
- +{marker.cardsDelta} {marker.cardsDelta === 1 ? 'card' : 'cards'}
-
- {loading ? (
-
Loading Anki note info...
- ) : null}
-
- {marker.noteIds.length > 0 ? (
- marker.noteIds.map((noteId) => {
- const info = noteInfos.get(noteId);
- const hasPreview = Boolean(info?.expression || info?.context || info?.meaning);
- const showUnavailableFallback = !loading && !hasPreview;
- return (
-
-
-
- Note {noteId}
-
- {showUnavailableFallback ? (
-
Preview unavailable
- ) : null}
+ {loading ? (
+
Loading Anki note info...
+ ) : null}
+
+ {marker.noteIds.length > 0 ? (
+ marker.noteIds.map((noteId) => {
+ const info = noteInfos.get(noteId);
+ const hasPreview = Boolean(info?.expression || info?.context || info?.meaning);
+ const showUnavailableFallback = !loading && !hasPreview;
+ return (
+
+
+
+ Note {noteId}
- {info?.expression ? (
-
- {info.expression}
-
- ) : null}
- {info?.context ? (
-
{info.context}
- ) : null}
- {info?.meaning ? (
-
{info.meaning}
- ) : null}
{showUnavailableFallback ? (
-
- Preview unavailable from AnkiConnect.
-
+
Preview unavailable
) : null}
-
- );
- })
- ) : (
-
No linked note ids recorded.
- )}
-
+ {info?.expression ? (
+
{info.expression}
+ ) : null}
+ {info?.context ? (
+
{info.context}
+ ) : null}
+ {info?.meaning ? (
+
{info.meaning}
+ ) : null}
+ {showUnavailableFallback ? (
+
+ Preview unavailable from AnkiConnect.
+
+ ) : null}
+
+
+ );
+ })
+ ) : (
+
No linked note ids recorded.
+ )}
- )}
+
);
}
diff --git a/stats/src/lib/formatters.test.ts b/stats/src/lib/formatters.test.ts
index 1f60926a..b842e5df 100644
--- a/stats/src/lib/formatters.test.ts
+++ b/stats/src/lib/formatters.test.ts
@@ -4,6 +4,7 @@ import test from 'node:test';
import {
epochDayToDate,
epochMsFromDbTimestamp,
+ formatClockDuration,
formatRelativeDate,
formatSessionDayLabel,
} from './formatters';
@@ -153,3 +154,9 @@ test('formatSessionDayLabel includes year for past-year dates', () => {
assert.notEqual(label, withoutYear);
}, fixedNow);
});
+
+test('formatClockDuration keeps seconds for sub-hour spans', () => {
+ assert.equal(formatClockDuration(42_000), '42s');
+ assert.equal(formatClockDuration(372_000), '6m 12s');
+ assert.equal(formatClockDuration(3_780_000), '1h 3m');
+});
diff --git a/stats/src/lib/formatters.ts b/stats/src/lib/formatters.ts
index 0184d154..e6ae3072 100644
--- a/stats/src/lib/formatters.ts
+++ b/stats/src/lib/formatters.ts
@@ -74,3 +74,17 @@ export function formatSessionDayLabel(sessionStartedAtMs: number): string {
...(includeYear ? { year: 'numeric' } : {}),
});
}
+
+/** Second-precise duration for short spans ("42s", "6m 12s", "1h 3m"). */
+export function formatClockDuration(ms: number): string {
+ const totalSec = Math.max(0, Math.round(ms / 1000));
+ if (totalSec < 60) return `${totalSec}s`;
+ const totalMin = Math.floor(totalSec / 60);
+ if (totalMin < 60) {
+ const sec = totalSec % 60;
+ return sec > 0 ? `${totalMin}m ${sec}s` : `${totalMin}m`;
+ }
+ const hours = Math.floor(totalMin / 60);
+ const mins = totalMin % 60;
+ return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`;
+}
diff --git a/stats/src/lib/session-density.test.ts b/stats/src/lib/session-density.test.ts
new file mode 100644
index 00000000..d9360570
--- /dev/null
+++ b/stats/src/lib/session-density.test.ts
@@ -0,0 +1,82 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { buildSessionDensity, pickDensityBucketMs } from './session-density';
+import { EventType } from '../types/stats';
+
+function sample(sampleMs: number, linesSeen: number, tokensSeen: number) {
+ return { sampleMs, totalWatchedMs: 0, activeWatchedMs: 0, linesSeen, tokensSeen, cardsMined: 0 };
+}
+
+test('pickDensityBucketMs grows the bucket until the session fits in the bucket budget', () => {
+ assert.equal(pickDensityBucketMs(5 * 60_000), 10_000);
+ assert.equal(pickDensityBucketMs(25 * 60_000), 30_000);
+ assert.equal(pickDensityBucketMs(3 * 3_600_000), 300_000);
+});
+
+test('pickDensityBucketMs scales past the largest candidate so very long spans stay in budget', () => {
+ const spanMs = 48 * 3_600_000;
+ const bucketMs = pickDensityBucketMs(spanMs);
+ assert.ok(Math.ceil(spanMs / bucketMs) <= 60);
+ assert.equal(bucketMs % 60_000, 0);
+});
+
+test('buildSessionDensity converts cumulative samples into per-bucket known/unknown deltas', () => {
+ const density = buildSessionDensity(
+ [sample(0, 0, 0), sample(5_000, 1, 10), sample(12_000, 2, 25), sample(25_000, 3, 40)],
+ [
+ { linesSeen: 1, knownWordsSeen: 4, totalWordsSeen: 8 },
+ { linesSeen: 2, knownWordsSeen: 10, totalWordsSeen: 20 },
+ { linesSeen: 3, knownWordsSeen: 20, totalWordsSeen: 32 },
+ ],
+ [
+ { eventType: EventType.YOMITAN_LOOKUP, tsMs: 3_000, payload: null },
+ { eventType: EventType.YOMITAN_LOOKUP, tsMs: 14_000, payload: null },
+ { eventType: EventType.CARD_MINED, tsMs: 24_000, payload: '{"cardsMined":2}' },
+ { eventType: EventType.PAUSE_START, tsMs: 24_500, payload: null },
+ ],
+ );
+
+ assert.equal(density.bucketMs, 10_000);
+ assert.equal(density.hasKnownWords, true);
+ assert.deepEqual(
+ density.buckets.map((b) => [b.knownWords, b.unknownWords, b.lookups, b.cards]),
+ [
+ [4, 4, 1, 0],
+ [6, 6, 1, 0],
+ [10, 2, 0, 2],
+ ],
+ );
+});
+
+test('buildSessionDensity falls back to raw token counts without known-word data', () => {
+ const density = buildSessionDensity(
+ [sample(0, 0, 0), sample(9_000, 1, 7), sample(15_000, 2, 12)],
+ [],
+ [],
+ );
+
+ assert.equal(density.hasKnownWords, false);
+ assert.deepEqual(
+ density.buckets.map((b) => [b.words, b.knownWords]),
+ [
+ [7, 0],
+ [5, 0],
+ ],
+ );
+});
+
+test('buildSessionDensity returns no buckets for an empty timeline', () => {
+ assert.deepEqual(buildSessionDensity([], [], []).buckets, []);
+});
+
+test('buildSessionDensity keeps the final sample when the span ends exactly on a bucket edge', () => {
+ const density = buildSessionDensity([sample(0, 0, 0), sample(300_000, 1, 42)], [], []);
+
+ assert.equal(density.bucketMs, 10_000);
+ assert.equal(density.buckets.length, 30);
+ assert.equal(density.buckets.at(-1)!.words, 42);
+ assert.equal(
+ density.buckets.reduce((sum, b) => sum + b.words, 0),
+ 42,
+ );
+});
diff --git a/stats/src/lib/session-density.ts b/stats/src/lib/session-density.ts
new file mode 100644
index 00000000..f4c5dad9
--- /dev/null
+++ b/stats/src/lib/session-density.ts
@@ -0,0 +1,149 @@
+import type { KnownWordsTimelinePoint } from '../hooks/useSessions';
+import { readCardsMinedDelta } from './session-events';
+import { EventType } from '../types/stats';
+import type { SessionEvent, SessionTimelinePoint } from '../types/stats';
+
+/** One time slice of a session: words read, lookups made, and cards mined inside it. */
+export interface SessionDensityBucket {
+ index: number;
+ startMs: number;
+ endMs: number;
+ words: number;
+ knownWords: number;
+ unknownWords: number;
+ lookups: number;
+ cards: number;
+}
+
+export interface SessionDensity {
+ bucketMs: number;
+ startMs: number;
+ endMs: number;
+ /** True when the known-words timeline had data, so known/unknown splits are meaningful. */
+ hasKnownWords: boolean;
+ buckets: SessionDensityBucket[];
+}
+
+const BUCKET_SIZE_CANDIDATES_MS = [
+ 10_000, 15_000, 30_000, 60_000, 120_000, 300_000, 600_000, 1_800_000,
+];
+
+export const MAX_DENSITY_BUCKETS = 60;
+
+/**
+ * Smallest candidate bucket size that keeps the whole span within maxBuckets.
+ * Spans too long for every candidate get whole-minute buckets sized to fit.
+ */
+export function pickDensityBucketMs(spanMs: number, maxBuckets = MAX_DENSITY_BUCKETS): number {
+ for (const candidate of BUCKET_SIZE_CANDIDATES_MS) {
+ if (Math.ceil(spanMs / candidate) <= maxBuckets) return candidate;
+ }
+ return Math.ceil(spanMs / maxBuckets / 60_000) * 60_000;
+}
+
+export function formatBucketSize(bucketMs: number): string {
+ if (bucketMs < 60_000) return `${Math.round(bucketMs / 1000)}s`;
+ const minutes = Math.round(bucketMs / 60_000);
+ return `${minutes}m`;
+}
+
+type KnownWordCounts = { knownWordsSeen: number; totalWordsSeen: number };
+
+/** Cumulative known/total counts at a lines-seen checkpoint, using the nearest earlier point. */
+function lookupKnownWordCounts(
+ sortedPoints: KnownWordsTimelinePoint[],
+ linesSeen: number,
+): KnownWordCounts {
+ let best: KnownWordCounts = { knownWordsSeen: 0, totalWordsSeen: 0 };
+ for (const point of sortedPoints) {
+ if (point.linesSeen > linesSeen) break;
+ best = point;
+ }
+ return best;
+}
+
+/**
+ * Turn cumulative telemetry samples into per-bucket deltas so the chart shows how
+ * dense the dialogue was over the session instead of a monotonically rising total.
+ */
+export function buildSessionDensity(
+ timeline: SessionTimelinePoint[],
+ knownWordsTimeline: KnownWordsTimelinePoint[],
+ events: SessionEvent[],
+): SessionDensity {
+ const samples = [...timeline].sort((a, b) => a.sampleMs - b.sampleMs);
+ if (samples.length === 0) {
+ return { bucketMs: 0, startMs: 0, endMs: 0, hasKnownWords: false, buckets: [] };
+ }
+
+ const knownPoints = [...knownWordsTimeline].sort((a, b) => a.linesSeen - b.linesSeen);
+ const hasKnownWords = knownPoints.length > 0;
+ const startMs = samples[0]!.sampleMs;
+ const lastSampleMs = samples[samples.length - 1]!.sampleMs;
+ const spanMs = Math.max(1, lastSampleMs - startMs);
+ const bucketMs = pickDensityBucketMs(spanMs);
+ const bucketCount = Math.max(1, Math.ceil(spanMs / bucketMs));
+ const endMs = startMs + bucketCount * bucketMs;
+
+ const buckets: SessionDensityBucket[] = [];
+ let sampleIndex = -1;
+ let prevWords = 0;
+ let prevKnown = 0;
+
+ for (let index = 0; index < bucketCount; index += 1) {
+ const bucketStart = startMs + index * bucketMs;
+ const bucketEnd = bucketStart + bucketMs;
+ // The last bucket also owns a sample sitting exactly on the session's end edge.
+ const isLastBucket = index === bucketCount - 1;
+ while (
+ sampleIndex + 1 < samples.length &&
+ (isLastBucket || samples[sampleIndex + 1]!.sampleMs < bucketEnd)
+ ) {
+ sampleIndex += 1;
+ }
+
+ let cumulativeWords = prevWords;
+ let cumulativeKnown = prevKnown;
+ if (sampleIndex >= 0) {
+ const sample = samples[sampleIndex]!;
+ if (hasKnownWords) {
+ const counts = lookupKnownWordCounts(knownPoints, sample.linesSeen);
+ cumulativeWords = counts.totalWordsSeen;
+ cumulativeKnown = Math.min(counts.knownWordsSeen, counts.totalWordsSeen);
+ } else {
+ cumulativeWords = sample.tokensSeen;
+ }
+ }
+
+ const words = Math.max(0, cumulativeWords - prevWords);
+ const knownWords = Math.min(words, Math.max(0, cumulativeKnown - prevKnown));
+ prevWords = Math.max(prevWords, cumulativeWords);
+ prevKnown = Math.max(prevKnown, cumulativeKnown);
+
+ buckets.push({
+ index,
+ startMs: bucketStart,
+ endMs: bucketEnd,
+ words,
+ knownWords,
+ unknownWords: words - knownWords,
+ lookups: 0,
+ cards: 0,
+ });
+ }
+
+ for (const event of events) {
+ if (event.eventType !== EventType.YOMITAN_LOOKUP && event.eventType !== EventType.CARD_MINED) {
+ continue;
+ }
+ const rawIndex = Math.floor((event.tsMs - startMs) / bucketMs);
+ const bucket = buckets[Math.max(0, Math.min(bucketCount - 1, rawIndex))]!;
+ if (event.eventType === EventType.YOMITAN_LOOKUP) {
+ bucket.lookups += 1;
+ } else {
+ bucket.cards += readCardsMinedDelta(event);
+ }
+ }
+
+ return { bucketMs, startMs, endMs, hasKnownWords, buckets };
+}
diff --git a/stats/src/lib/session-detail.test.tsx b/stats/src/lib/session-detail.test.tsx
deleted file mode 100644
index 4a5c6eef..00000000
--- a/stats/src/lib/session-detail.test.tsx
+++ /dev/null
@@ -1,62 +0,0 @@
-import assert from 'node:assert/strict';
-import test from 'node:test';
-import {
- buildKnownWordsRatioChartData,
- getKnownPctAxisMax,
-} from '../components/sessions/SessionDetail';
-import { buildSessionChartEvents } from './session-events';
-import { EventType } from '../types/stats';
-
-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}' },
- ]);
-
- // Seek events are intentionally dropped from the chart — they were too noisy.
- assert.deepEqual(
- chartEvents.markers.filter((marker) => marker.kind !== 'pause' && marker.kind !== 'card'),
- [],
- );
- 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 }]);
-});
-
-test('getKnownPctAxisMax adds headroom above the highest known percentage', () => {
- assert.equal(getKnownPctAxisMax([22.4, 31.2, 29.8]), 40);
-});
-
-test('getKnownPctAxisMax caps the chart top at 100%', () => {
- assert.equal(getKnownPctAxisMax([97.1, 98.6]), 100);
-});
-
-test('buildKnownWordsRatioChartData uses filtered known-word timeline totals', () => {
- const chartData = buildKnownWordsRatioChartData(
- [
- { sampleMs: 1_000, linesSeen: 1, tokensSeen: 10 },
- { sampleMs: 2_000, linesSeen: 2, tokensSeen: 20 },
- ],
- new Map([
- [1, { knownWordsSeen: 2, totalWordsSeen: 3 }],
- [2, { knownWordsSeen: 3, totalWordsSeen: 4 }],
- ]),
- );
-
- assert.deepEqual(chartData, [
- { tsMs: 1_000, knownWords: 2, unknownWords: 1, totalWords: 3 },
- { tsMs: 2_000, knownWords: 3, unknownWords: 1, totalWords: 4 },
- ]);
-});
diff --git a/stats/src/lib/session-events.test.ts b/stats/src/lib/session-events.test.ts
index a8a3fcbc..87adcefe 100644
--- a/stats/src/lib/session-events.test.ts
+++ b/stats/src/lib/session-events.test.ts
@@ -29,20 +29,13 @@ test('buildSessionChartEvents produces typed hover markers with parsed payload m
{ eventType: EventType.YOMITAN_LOOKUP, tsMs: 7_000, payload: null },
]);
- // Seek events are intentionally dropped — too noisy on the session chart.
+ // Pause and seek events are intentionally dropped — too noisy on the session chart.
assert.deepEqual(
chartEvents.markers.map((marker) => marker.kind),
- ['pause', 'card'],
+ ['card'],
);
- const pauseMarker = chartEvents.markers[0]!;
- assert.equal(pauseMarker.kind, 'pause');
- assert.equal(pauseMarker.startMs, 2_000);
- assert.equal(pauseMarker.endMs, 5_000);
- assert.equal(pauseMarker.durationMs, 3_000);
- assert.equal(pauseMarker.anchorTsMs, 3_500);
-
- const cardMarker = chartEvents.markers[1]!;
+ const cardMarker = chartEvents.markers[0]!;
assert.equal(cardMarker.kind, 'card');
assert.deepEqual(cardMarker.noteIds, [11, 22]);
assert.equal(cardMarker.cardsDelta, 2);
diff --git a/stats/src/lib/session-events.ts b/stats/src/lib/session-events.ts
index 1ef473f9..e8ea9acb 100644
--- a/stats/src/lib/session-events.ts
+++ b/stats/src/lib/session-events.ts
@@ -1,21 +1,10 @@
import { EventType, type SessionEvent } from '../types/stats';
-export const SESSION_CHART_EVENT_TYPES = [
- EventType.CARD_MINED,
- EventType.PAUSE_START,
- EventType.PAUSE_END,
- EventType.YOMITAN_LOOKUP,
-] as const;
-
-export interface PauseRegion {
- startMs: number;
- endMs: number;
-}
+export const SESSION_CHART_EVENT_TYPES = [EventType.CARD_MINED, EventType.YOMITAN_LOOKUP] as const;
export interface SessionChartEvents {
cardEvents: SessionEvent[];
yomitanLookupEvents: SessionEvent[];
- pauseRegions: PauseRegion[];
markers: SessionChartMarker[];
}
@@ -45,24 +34,15 @@ interface SessionEventNoteRecord {
fields?: Record
| null;
}
-export type SessionChartMarker =
- | {
- key: string;
- kind: 'pause';
- anchorTsMs: number;
- eventTsMs: number;
- startMs: number;
- endMs: number;
- durationMs: number;
- }
- | {
- key: string;
- kind: 'card';
- anchorTsMs: number;
- eventTsMs: number;
- noteIds: number[];
- cardsDelta: number;
- };
+/** Interactive card-mined marker shown on the session event lane. */
+export interface SessionChartMarker {
+ key: string;
+ kind: 'card';
+ anchorTsMs: number;
+ eventTsMs: number;
+ noteIds: number[];
+ cardsDelta: number;
+}
function parsePayload(payload: string | null): Record | null {
if (!payload) return null;
@@ -85,6 +65,11 @@ function readNoteIds(value: unknown): number[] {
);
}
+/** Cards added by one CARD_MINED event; older events without a count mean one card. */
+export function readCardsMinedDelta(event: SessionEvent): number {
+ return readNumberField(parsePayload(event.payload)?.cardsMined) ?? 1;
+}
+
function stripHtml(value: string): string {
return value
.replace(/\[sound:[^\]]+\]/gi, ' ')
@@ -257,11 +242,6 @@ export function togglePinnedSessionMarkerKey(
return currentPinnedMarkerKey === nextMarkerKey ? null : nextMarkerKey;
}
-export function formatEventSeconds(ms: number | null): string | null {
- if (ms == null || !Number.isFinite(ms)) return null;
- return `${(ms / 1000).toFixed(1)}s`;
-}
-
export function projectSessionMarkerLeftPx({
anchorTsMs,
tsMin,
@@ -284,71 +264,32 @@ export function projectSessionMarkerLeftPx({
export function buildSessionChartEvents(events: SessionEvent[]): SessionChartEvents {
const cardEvents: SessionEvent[] = [];
const yomitanLookupEvents: SessionEvent[] = [];
- const pauseRegions: PauseRegion[] = [];
const markers: SessionChartMarker[] = [];
- let pendingPauseStartMs: number | null = null;
for (const event of events) {
switch (event.eventType) {
- case EventType.CARD_MINED:
+ case EventType.CARD_MINED: {
cardEvents.push(event);
- {
- const payload = parsePayload(event.payload);
- markers.push({
- key: `card-${event.tsMs}`,
- kind: 'card',
- anchorTsMs: event.tsMs,
- eventTsMs: event.tsMs,
- noteIds: readNoteIds(payload?.noteIds),
- cardsDelta: readNumberField(payload?.cardsMined) ?? 1,
- });
- }
+ const payload = parsePayload(event.payload);
+ markers.push({
+ key: `card-${event.tsMs}`,
+ kind: 'card',
+ anchorTsMs: event.tsMs,
+ eventTsMs: event.tsMs,
+ noteIds: readNoteIds(payload?.noteIds),
+ cardsDelta: readCardsMinedDelta(event),
+ });
break;
+ }
case EventType.YOMITAN_LOOKUP:
yomitanLookupEvents.push(event);
break;
- case EventType.PAUSE_START:
- pendingPauseStartMs = event.tsMs;
- break;
- case EventType.PAUSE_END:
- if (pendingPauseStartMs !== null) {
- pauseRegions.push({ startMs: pendingPauseStartMs, endMs: event.tsMs });
- markers.push({
- key: `pause-${pendingPauseStartMs}-${event.tsMs}`,
- kind: 'pause',
- anchorTsMs: pendingPauseStartMs + Math.round((event.tsMs - pendingPauseStartMs) / 2),
- eventTsMs: pendingPauseStartMs,
- startMs: pendingPauseStartMs,
- endMs: event.tsMs,
- durationMs: Math.max(0, event.tsMs - pendingPauseStartMs),
- });
- pendingPauseStartMs = null;
- }
- break;
default:
break;
}
}
- if (pendingPauseStartMs !== null) {
- pauseRegions.push({ startMs: pendingPauseStartMs, endMs: pendingPauseStartMs + 2_000 });
- markers.push({
- key: `pause-${pendingPauseStartMs}-${pendingPauseStartMs + 2_000}`,
- kind: 'pause',
- anchorTsMs: pendingPauseStartMs + 1_000,
- eventTsMs: pendingPauseStartMs,
- startMs: pendingPauseStartMs,
- endMs: pendingPauseStartMs + 2_000,
- durationMs: 2_000,
- });
- }
-
markers.sort((left, right) => left.anchorTsMs - right.anchorTsMs);
- return {
- cardEvents,
- yomitanLookupEvents,
- pauseRegions,
- markers,
- };
+ return { cardEvents, yomitanLookupEvents, markers };
}