mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-10-26 23:35:17 -07:00
feat(stats): redesign expanded session view with density chart (#304)
This commit is contained in:
1 parent
cc9af79d6e
commit
6219a81144
13 files changed
+695
-787
No files matched your search
@@ -0,0 +1,4 @@
|
|||||||
|
type: changed
|
||||||
|
area: stats
|
||||||
|
|
||||||
|
- Redesigned the expanded session view in the stats dashboard: stat tiles for pace, lines, known-word rate, lookups, and paused time; a reading density chart of known and unknown words per time slice replaces the cumulative area chart; Yomitan lookups and card-mined markers sit in their own lane under the chart; pause markers and pause shading are gone.
|
||||||
@@ -70,7 +70,7 @@ Charts for watch time, cards, words, and sessions per day or month, running tota
|
|||||||
|
|
||||||
### Sessions
|
### Sessions
|
||||||
|
|
||||||
Session history with new-word activity and pause, seek, and card markers. The **↗** button on a row opens that show's detail view.
|
Session history. Expand a session for its pace, lines, known-word rate, lookups, and paused time, plus a reading density chart of known and unknown words over the session with Yomitan lookup ticks and clickable card-mined markers underneath. The **↗** button on a row opens that show's detail view.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { buildSessionStatTiles, pickDensityTickIndexes } from './SessionDetail';
|
||||||
|
import type { SessionSummary } from '../../types/stats';
|
||||||
|
|
||||||
|
const session: SessionSummary = {
|
||||||
|
sessionId: 1,
|
||||||
|
canonicalTitle: 'Terrace House S01E08',
|
||||||
|
videoId: 1,
|
||||||
|
animeId: null,
|
||||||
|
animeTitle: null,
|
||||||
|
startedAtMs: 0,
|
||||||
|
endedAtMs: 1_860_000,
|
||||||
|
totalWatchedMs: 1_860_000,
|
||||||
|
activeWatchedMs: 1_500_000,
|
||||||
|
linesSeen: 400,
|
||||||
|
tokensSeen: 1_684,
|
||||||
|
cardsMined: 0,
|
||||||
|
lookupCount: 0,
|
||||||
|
lookupHits: 0,
|
||||||
|
yomitanLookupCount: 18,
|
||||||
|
knownWordsSeen: 673,
|
||||||
|
knownWordRate: 40,
|
||||||
|
};
|
||||||
|
|
||||||
|
test('buildSessionStatTiles derives pace, known rate, lookup rate, and paused time', () => {
|
||||||
|
const tiles = Object.fromEntries(buildSessionStatTiles(session).map((t) => [t.key, t]));
|
||||||
|
assert.equal(tiles.pace!.value, '67 words/min');
|
||||||
|
assert.equal(tiles.pace!.detail, '16.0 lines/min');
|
||||||
|
assert.equal(tiles.known!.value, '40%');
|
||||||
|
assert.equal(tiles.lookups!.detail, '1.1 lookups per 100 words');
|
||||||
|
assert.equal(tiles.paused!.value, '6m');
|
||||||
|
assert.equal(tiles.paused!.detail, '19% of 31m wall time');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildSessionStatTiles shows placeholders when there is no active time or known data', () => {
|
||||||
|
const tiles = Object.fromEntries(
|
||||||
|
buildSessionStatTiles({
|
||||||
|
...session,
|
||||||
|
activeWatchedMs: 0,
|
||||||
|
totalWatchedMs: 0,
|
||||||
|
knownWordsSeen: 0,
|
||||||
|
knownWordRate: 0,
|
||||||
|
}).map((t) => [t.key, t]),
|
||||||
|
);
|
||||||
|
assert.equal(tiles.pace!.value, '—');
|
||||||
|
assert.equal(tiles.known!.value, '—');
|
||||||
|
assert.equal(tiles.paused!.value, 'none');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pickDensityTickIndexes spaces about five labels across the buckets', () => {
|
||||||
|
assert.deepEqual(pickDensityTickIndexes(3), [0, 1, 2]);
|
||||||
|
assert.deepEqual(pickDensityTickIndexes(50), [0, 10, 20, 30, 40]);
|
||||||
|
assert.deepEqual(pickDensityTickIndexes(0), []);
|
||||||
|
});
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -29,23 +29,12 @@ function toPercent(tsMs: number, tsMin: number, tsMax: number): number {
|
|||||||
return Math.max(0, Math.min(100, ratio));
|
return Math.max(0, Math.min(100, ratio));
|
||||||
}
|
}
|
||||||
|
|
||||||
function markerLabel(marker: SessionChartMarker): string {
|
const CARD_MARKER_LABEL = '\u26CF';
|
||||||
switch (marker.kind) {
|
const CARD_MARKER_COLORS = {
|
||||||
case 'pause':
|
border: '#f5bde6',
|
||||||
return '||';
|
bg: 'rgba(245,189,230,0.18)',
|
||||||
case 'card':
|
text: '#f5bde6',
|
||||||
return '\u26CF';
|
};
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function markerColors(marker: SessionChartMarker): { border: string; bg: string; text: string } {
|
|
||||||
switch (marker.kind) {
|
|
||||||
case 'pause':
|
|
||||||
return { border: '#f5a97f', bg: 'rgba(245,169,127,0.16)', text: '#f5a97f' };
|
|
||||||
case 'card':
|
|
||||||
return { border: '#a6da95', bg: 'rgba(166,218,149,0.16)', text: '#a6da95' };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function popupAlignment(percent: number): string {
|
function popupAlignment(percent: number): string {
|
||||||
if (percent <= 15) return 'left-0 translate-x-0';
|
if (percent <= 15) return 'left-0 translate-x-0';
|
||||||
@@ -137,16 +126,14 @@ export function SessionEventOverlay({
|
|||||||
plotWidthPx: plotArea.width,
|
plotWidthPx: plotArea.width,
|
||||||
})}px`
|
})}px`
|
||||||
: `${percent}%`;
|
: `${percent}%`;
|
||||||
const colors = markerColors(marker);
|
|
||||||
const isActive = marker.key === activeMarkerKey;
|
const isActive = marker.key === activeMarkerKey;
|
||||||
const isPinned = marker.key === pinnedMarkerKey;
|
const isPinned = marker.key === pinnedMarkerKey;
|
||||||
const loading =
|
const loading = marker.noteIds.some((noteId) => loadingNoteIds.has(noteId));
|
||||||
marker.kind === 'card' && marker.noteIds.some((noteId) => loadingNoteIds.has(noteId));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={marker.key}
|
key={marker.key}
|
||||||
className="pointer-events-auto absolute top-0 -translate-x-1/2 pt-1"
|
className="pointer-events-auto absolute top-1/2 -translate-x-1/2 -translate-y-1/2"
|
||||||
style={{ left }}
|
style={{ left }}
|
||||||
onMouseEnter={() => onHoveredMarkerChange(marker.key)}
|
onMouseEnter={() => onHoveredMarkerChange(marker.key)}
|
||||||
onMouseLeave={(event) =>
|
onMouseLeave={(event) =>
|
||||||
@@ -160,13 +147,14 @@ export function SessionEventOverlay({
|
|||||||
<div className="relative flex flex-col items-center">
|
<div className="relative flex flex-col items-center">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={`Show ${marker.kind} event details`}
|
aria-label="Show card mined event details"
|
||||||
aria-pressed={isPinned}
|
aria-pressed={isPinned}
|
||||||
className="flex h-5 min-w-5 items-center justify-center rounded-full border px-1 text-[10px] font-semibold shadow-sm backdrop-blur-sm"
|
title={`${marker.cardsDelta} ${marker.cardsDelta === 1 ? 'card' : 'cards'} mined`}
|
||||||
|
className="flex h-5 min-w-5 items-center justify-center rounded-full border px-1 text-[10px] font-semibold shadow-sm backdrop-blur-sm transition-transform hover:scale-110"
|
||||||
style={{
|
style={{
|
||||||
borderColor: colors.border,
|
borderColor: CARD_MARKER_COLORS.border,
|
||||||
background: colors.bg,
|
background: CARD_MARKER_COLORS.bg,
|
||||||
color: colors.text,
|
color: CARD_MARKER_COLORS.text,
|
||||||
}}
|
}}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -175,7 +163,7 @@ export function SessionEventOverlay({
|
|||||||
onPinnedMarkerChange(togglePinnedSessionMarkerKey(pinnedMarkerKey, marker.key));
|
onPinnedMarkerChange(togglePinnedSessionMarkerKey(pinnedMarkerKey, marker.key));
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{markerLabel(marker)}
|
{CARD_MARKER_LABEL}
|
||||||
</button>
|
</button>
|
||||||
{isActive ? (
|
{isActive ? (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1,8 +1,4 @@
|
|||||||
import {
|
import type { SessionChartMarker, SessionEventNoteInfo } from '../../lib/session-events';
|
||||||
formatEventSeconds,
|
|
||||||
type SessionChartMarker,
|
|
||||||
type SessionEventNoteInfo,
|
|
||||||
} from '../../lib/session-events';
|
|
||||||
|
|
||||||
interface SessionEventPopoverProps {
|
interface SessionEventPopoverProps {
|
||||||
marker: SessionChartMarker;
|
marker: SessionChartMarker;
|
||||||
@@ -35,10 +31,7 @@ export function SessionEventPopover({
|
|||||||
<div className="relative z-50 w-64 rounded-xl border border-ctp-surface2 bg-ctp-surface0/95 p-3 shadow-2xl shadow-black/30 backdrop-blur-sm">
|
<div className="relative z-50 w-64 rounded-xl border border-ctp-surface2 bg-ctp-surface0/95 p-3 shadow-2xl shadow-black/30 backdrop-blur-sm">
|
||||||
<div className="mb-2 flex items-start justify-between gap-3">
|
<div className="mb-2 flex items-start justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs font-semibold text-ctp-text">
|
<div className="text-xs font-semibold text-ctp-text">Card mined</div>
|
||||||
{marker.kind === 'pause' && 'Paused'}
|
|
||||||
{marker.kind === 'card' && 'Card mined'}
|
|
||||||
</div>
|
|
||||||
<div className="text-[10px] text-ctp-overlay1">{formatEventTime(marker.eventTsMs)}</div>
|
<div className="text-[10px] text-ctp-overlay1">{formatEventTime(marker.eventTsMs)}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
@@ -64,20 +57,10 @@ export function SessionEventPopover({
|
|||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="text-sm">
|
<div className="text-sm text-ctp-cards-mined">{'\u26CF'}</div>
|
||||||
{marker.kind === 'pause' && '||'}
|
|
||||||
{marker.kind === 'card' && '\u26CF'}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{marker.kind === 'pause' && (
|
|
||||||
<div className="text-xs text-ctp-subtext0">
|
|
||||||
Duration: <span className="text-ctp-peach">{formatEventSeconds(marker.durationMs)}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{marker.kind === 'card' && (
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="text-xs text-ctp-cards-mined">
|
<div className="text-xs text-ctp-cards-mined">
|
||||||
+{marker.cardsDelta} {marker.cardsDelta === 1 ? 'card' : 'cards'}
|
+{marker.cardsDelta} {marker.cardsDelta === 1 ? 'card' : 'cards'}
|
||||||
@@ -105,9 +88,7 @@ export function SessionEventPopover({
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{info?.expression ? (
|
{info?.expression ? (
|
||||||
<div className="mb-1 text-sm font-medium text-ctp-text">
|
<div className="mb-1 text-sm font-medium text-ctp-text">{info.expression}</div>
|
||||||
{info.expression}
|
|
||||||
</div>
|
|
||||||
) : null}
|
) : null}
|
||||||
{info?.context ? (
|
{info?.context ? (
|
||||||
<div className="mb-1 text-xs text-ctp-subtext0">{info.context}</div>
|
<div className="mb-1 text-xs text-ctp-subtext0">{info.context}</div>
|
||||||
@@ -135,7 +116,6 @@ export function SessionEventPopover({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -4,6 +4,7 @@ import test from 'node:test';
|
|||||||
import {
|
import {
|
||||||
epochDayToDate,
|
epochDayToDate,
|
||||||
epochMsFromDbTimestamp,
|
epochMsFromDbTimestamp,
|
||||||
|
formatClockDuration,
|
||||||
formatRelativeDate,
|
formatRelativeDate,
|
||||||
formatSessionDayLabel,
|
formatSessionDayLabel,
|
||||||
} from './formatters';
|
} from './formatters';
|
||||||
@@ -153,3 +154,9 @@ test('formatSessionDayLabel includes year for past-year dates', () => {
|
|||||||
assert.notEqual(label, withoutYear);
|
assert.notEqual(label, withoutYear);
|
||||||
}, fixedNow);
|
}, 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');
|
||||||
|
});
|
||||||
@@ -74,3 +74,17 @@ export function formatSessionDayLabel(sessionStartedAtMs: number): string {
|
|||||||
...(includeYear ? { year: 'numeric' } : {}),
|
...(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`;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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 },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
@@ -29,20 +29,13 @@ test('buildSessionChartEvents produces typed hover markers with parsed payload m
|
|||||||
{ eventType: EventType.YOMITAN_LOOKUP, tsMs: 7_000, payload: null },
|
{ 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(
|
assert.deepEqual(
|
||||||
chartEvents.markers.map((marker) => marker.kind),
|
chartEvents.markers.map((marker) => marker.kind),
|
||||||
['pause', 'card'],
|
['card'],
|
||||||
);
|
);
|
||||||
|
|
||||||
const pauseMarker = chartEvents.markers[0]!;
|
const cardMarker = 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]!;
|
|
||||||
assert.equal(cardMarker.kind, 'card');
|
assert.equal(cardMarker.kind, 'card');
|
||||||
assert.deepEqual(cardMarker.noteIds, [11, 22]);
|
assert.deepEqual(cardMarker.noteIds, [11, 22]);
|
||||||
assert.equal(cardMarker.cardsDelta, 2);
|
assert.equal(cardMarker.cardsDelta, 2);
|
||||||
|
|||||||
@@ -1,21 +1,10 @@
|
|||||||
import { EventType, type SessionEvent } from '../types/stats';
|
import { EventType, type SessionEvent } from '../types/stats';
|
||||||
|
|
||||||
export const SESSION_CHART_EVENT_TYPES = [
|
export const SESSION_CHART_EVENT_TYPES = [EventType.CARD_MINED, EventType.YOMITAN_LOOKUP] as const;
|
||||||
EventType.CARD_MINED,
|
|
||||||
EventType.PAUSE_START,
|
|
||||||
EventType.PAUSE_END,
|
|
||||||
EventType.YOMITAN_LOOKUP,
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export interface PauseRegion {
|
|
||||||
startMs: number;
|
|
||||||
endMs: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SessionChartEvents {
|
export interface SessionChartEvents {
|
||||||
cardEvents: SessionEvent[];
|
cardEvents: SessionEvent[];
|
||||||
yomitanLookupEvents: SessionEvent[];
|
yomitanLookupEvents: SessionEvent[];
|
||||||
pauseRegions: PauseRegion[];
|
|
||||||
markers: SessionChartMarker[];
|
markers: SessionChartMarker[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,24 +34,15 @@ interface SessionEventNoteRecord {
|
|||||||
fields?: Record<string, SessionEventNoteField> | null;
|
fields?: Record<string, SessionEventNoteField> | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SessionChartMarker =
|
/** Interactive card-mined marker shown on the session event lane. */
|
||||||
| {
|
export interface SessionChartMarker {
|
||||||
key: string;
|
|
||||||
kind: 'pause';
|
|
||||||
anchorTsMs: number;
|
|
||||||
eventTsMs: number;
|
|
||||||
startMs: number;
|
|
||||||
endMs: number;
|
|
||||||
durationMs: number;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
key: string;
|
key: string;
|
||||||
kind: 'card';
|
kind: 'card';
|
||||||
anchorTsMs: number;
|
anchorTsMs: number;
|
||||||
eventTsMs: number;
|
eventTsMs: number;
|
||||||
noteIds: number[];
|
noteIds: number[];
|
||||||
cardsDelta: number;
|
cardsDelta: number;
|
||||||
};
|
}
|
||||||
|
|
||||||
function parsePayload(payload: string | null): Record<string, unknown> | null {
|
function parsePayload(payload: string | null): Record<string, unknown> | null {
|
||||||
if (!payload) return 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 {
|
function stripHtml(value: string): string {
|
||||||
return value
|
return value
|
||||||
.replace(/\[sound:[^\]]+\]/gi, ' ')
|
.replace(/\[sound:[^\]]+\]/gi, ' ')
|
||||||
@@ -257,11 +242,6 @@ export function togglePinnedSessionMarkerKey(
|
|||||||
return currentPinnedMarkerKey === nextMarkerKey ? null : nextMarkerKey;
|
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({
|
export function projectSessionMarkerLeftPx({
|
||||||
anchorTsMs,
|
anchorTsMs,
|
||||||
tsMin,
|
tsMin,
|
||||||
@@ -284,15 +264,12 @@ export function projectSessionMarkerLeftPx({
|
|||||||
export function buildSessionChartEvents(events: SessionEvent[]): SessionChartEvents {
|
export function buildSessionChartEvents(events: SessionEvent[]): SessionChartEvents {
|
||||||
const cardEvents: SessionEvent[] = [];
|
const cardEvents: SessionEvent[] = [];
|
||||||
const yomitanLookupEvents: SessionEvent[] = [];
|
const yomitanLookupEvents: SessionEvent[] = [];
|
||||||
const pauseRegions: PauseRegion[] = [];
|
|
||||||
const markers: SessionChartMarker[] = [];
|
const markers: SessionChartMarker[] = [];
|
||||||
let pendingPauseStartMs: number | null = null;
|
|
||||||
|
|
||||||
for (const event of events) {
|
for (const event of events) {
|
||||||
switch (event.eventType) {
|
switch (event.eventType) {
|
||||||
case EventType.CARD_MINED:
|
case EventType.CARD_MINED: {
|
||||||
cardEvents.push(event);
|
cardEvents.push(event);
|
||||||
{
|
|
||||||
const payload = parsePayload(event.payload);
|
const payload = parsePayload(event.payload);
|
||||||
markers.push({
|
markers.push({
|
||||||
key: `card-${event.tsMs}`,
|
key: `card-${event.tsMs}`,
|
||||||
@@ -300,55 +277,19 @@ export function buildSessionChartEvents(events: SessionEvent[]): SessionChartEve
|
|||||||
anchorTsMs: event.tsMs,
|
anchorTsMs: event.tsMs,
|
||||||
eventTsMs: event.tsMs,
|
eventTsMs: event.tsMs,
|
||||||
noteIds: readNoteIds(payload?.noteIds),
|
noteIds: readNoteIds(payload?.noteIds),
|
||||||
cardsDelta: readNumberField(payload?.cardsMined) ?? 1,
|
cardsDelta: readCardsMinedDelta(event),
|
||||||
});
|
});
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
case EventType.YOMITAN_LOOKUP:
|
case EventType.YOMITAN_LOOKUP:
|
||||||
yomitanLookupEvents.push(event);
|
yomitanLookupEvents.push(event);
|
||||||
break;
|
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:
|
default:
|
||||||
break;
|
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);
|
markers.sort((left, right) => left.anchorTsMs - right.anchorTsMs);
|
||||||
|
|
||||||
return {
|
return { cardEvents, yomitanLookupEvents, markers };
|
||||||
cardEvents,
|
|
||||||
yomitanLookupEvents,
|
|
||||||
pauseRegions,
|
|
||||||
markers,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user