feat(stats): redesign expanded session view with density chart (#304)

This commit is contained in:
sudacode authored and GitHub committed 2026-10-26 23:08:48 -07:00
1 parent cc9af79d6e
commit 6219a81144
13 files changed
+798 -890

No files matched your search

@@ -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));
}
function markerLabel(marker: SessionChartMarker): string {
switch (marker.kind) {
case 'pause':
return '||';
case 'card':
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' };
}
}
const CARD_MARKER_LABEL = '\u26CF';
const CARD_MARKER_COLORS = {
border: '#f5bde6',
bg: 'rgba(245,189,230,0.18)',
text: '#f5bde6',
};
function popupAlignment(percent: number): string {
if (percent <= 15) return 'left-0 translate-x-0';
@@ -137,16 +126,14 @@ export function SessionEventOverlay({
plotWidthPx: plotArea.width,
})}px`
: `${percent}%`;
const colors = markerColors(marker);
const isActive = marker.key === activeMarkerKey;
const isPinned = marker.key === pinnedMarkerKey;
const loading =
marker.kind === 'card' && marker.noteIds.some((noteId) => loadingNoteIds.has(noteId));
const loading = marker.noteIds.some((noteId) => loadingNoteIds.has(noteId));
return (
<div
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 }}
onMouseEnter={() => onHoveredMarkerChange(marker.key)}
onMouseLeave={(event) =>
@@ -160,13 +147,14 @@ export function SessionEventOverlay({
<div className="relative flex flex-col items-center">
<button
type="button"
aria-label={`Show ${marker.kind} event details`}
aria-label="Show card mined event details"
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={{
borderColor: colors.border,
background: colors.bg,
color: colors.text,
borderColor: CARD_MARKER_COLORS.border,
background: CARD_MARKER_COLORS.bg,
color: CARD_MARKER_COLORS.text,
}}
onClick={(event) => {
event.preventDefault();
@@ -175,7 +163,7 @@ export function SessionEventOverlay({
onPinnedMarkerChange(togglePinnedSessionMarkerKey(pinnedMarkerKey, marker.key));
}}
>
{markerLabel(marker)}
{CARD_MARKER_LABEL}
</button>
{isActive ? (
<div
@@ -1,8 +1,4 @@
import {
formatEventSeconds,
type SessionChartMarker,
type SessionEventNoteInfo,
} from '../../lib/session-events';
import type { SessionChartMarker, SessionEventNoteInfo } from '../../lib/session-events';
interface SessionEventPopoverProps {
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="mb-2 flex items-start justify-between gap-3">
<div>
<div className="text-xs font-semibold text-ctp-text">
{marker.kind === 'pause' && 'Paused'}
{marker.kind === 'card' && 'Card mined'}
</div>
<div className="text-xs font-semibold text-ctp-text">Card mined</div>
<div className="text-[10px] text-ctp-overlay1">{formatEventTime(marker.eventTsMs)}</div>
</div>
<div className="flex items-center gap-1.5">
@@ -64,78 +57,65 @@ export function SessionEventPopover({
×
</button>
) : null}
<div className="text-sm">
{marker.kind === 'pause' && '||'}
{marker.kind === 'card' && '\u26CF'}
</div>
<div className="text-sm text-ctp-cards-mined">{'\u26CF'}</div>
</div>
</div>
{marker.kind === 'pause' && (
<div className="text-xs text-ctp-subtext0">
Duration: <span className="text-ctp-peach">{formatEventSeconds(marker.durationMs)}</span>
<div className="space-y-2">
<div className="text-xs text-ctp-cards-mined">
+{marker.cardsDelta} {marker.cardsDelta === 1 ? 'card' : 'cards'}
</div>
)}
{marker.kind === 'card' && (
<div className="space-y-2">
<div className="text-xs text-ctp-cards-mined">
+{marker.cardsDelta} {marker.cardsDelta === 1 ? 'card' : 'cards'}
</div>
{loading ? (
<div className="text-xs text-ctp-overlay1">Loading Anki note info...</div>
) : null}
<div className="space-y-1.5">
{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 (
<div
key={noteId}
className="rounded-lg border border-ctp-surface1 bg-ctp-mantle/80 px-2.5 py-2"
>
<div className="mb-1 flex items-center justify-between gap-2">
<div className="rounded-full bg-ctp-surface1 px-1.5 py-0.5 text-[10px] uppercase tracking-wide text-ctp-overlay1">
Note {noteId}
</div>
{showUnavailableFallback ? (
<div className="text-[10px] text-ctp-overlay1">Preview unavailable</div>
) : null}
{loading ? (
<div className="text-xs text-ctp-overlay1">Loading Anki note info...</div>
) : null}
<div className="space-y-1.5">
{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 (
<div
key={noteId}
className="rounded-lg border border-ctp-surface1 bg-ctp-mantle/80 px-2.5 py-2"
>
<div className="mb-1 flex items-center justify-between gap-2">
<div className="rounded-full bg-ctp-surface1 px-1.5 py-0.5 text-[10px] uppercase tracking-wide text-ctp-overlay1">
Note {noteId}
</div>
{info?.expression ? (
<div className="mb-1 text-sm font-medium text-ctp-text">
{info.expression}
</div>
) : null}
{info?.context ? (
<div className="mb-1 text-xs text-ctp-subtext0">{info.context}</div>
) : null}
{info?.meaning ? (
<div className="mb-2 text-xs text-ctp-teal">{info.meaning}</div>
) : null}
{showUnavailableFallback ? (
<div className="mb-2 text-xs text-ctp-overlay1">
Preview unavailable from AnkiConnect.
</div>
<div className="text-[10px] text-ctp-overlay1">Preview unavailable</div>
) : null}
<button
type="button"
onClick={() => onOpenNote(noteId)}
className="rounded-md bg-ctp-surface1 px-2 py-1 text-[10px] text-ctp-blue transition-colors hover:bg-ctp-surface2"
>
Open in Anki
</button>
</div>
);
})
) : (
<div className="text-xs text-ctp-overlay1">No linked note ids recorded.</div>
)}
</div>
{info?.expression ? (
<div className="mb-1 text-sm font-medium text-ctp-text">{info.expression}</div>
) : null}
{info?.context ? (
<div className="mb-1 text-xs text-ctp-subtext0">{info.context}</div>
) : null}
{info?.meaning ? (
<div className="mb-2 text-xs text-ctp-teal">{info.meaning}</div>
) : null}
{showUnavailableFallback ? (
<div className="mb-2 text-xs text-ctp-overlay1">
Preview unavailable from AnkiConnect.
</div>
) : null}
<button
type="button"
onClick={() => onOpenNote(noteId)}
className="rounded-md bg-ctp-surface1 px-2 py-1 text-[10px] text-ctp-blue transition-colors hover:bg-ctp-surface2"
>
Open in Anki
</button>
</div>
);
})
) : (
<div className="text-xs text-ctp-overlay1">No linked note ids recorded.</div>
)}
</div>
)}
</div>
</div>
);
}