mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-11-10 16:46:06 -08:00
feat(stats): redesign expanded session view with density chart (#304)
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user