diff --git a/changes/session-detail-redesign.md b/changes/session-detail-redesign.md new file mode 100644 index 00000000..a7f17634 --- /dev/null +++ b/changes/session-detail-redesign.md @@ -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. diff --git a/docs-site/immersion-tracking.md b/docs-site/immersion-tracking.md index ce281114..ac967c5f 100644 --- a/docs-site/immersion-tracking.md +++ b/docs-site/immersion-tracking.md @@ -70,7 +70,7 @@ Charts for watch time, cards, words, and sessions per day or month, running tota ### 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. ![Stats Sessions](/screenshots/stats-sessions.png) diff --git a/stats/src/components/sessions/SessionDetail.test.tsx b/stats/src/components/sessions/SessionDetail.test.tsx new file mode 100644 index 00000000..5fcb36a2 --- /dev/null +++ b/stats/src/components/sessions/SessionDetail.test.tsx @@ -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), []); +}); diff --git a/stats/src/components/sessions/SessionDetail.tsx b/stats/src/components/sessions/SessionDetail.tsx index 52ee8006..d6fb229d 100644 --- a/stats/src/components/sessions/SessionDetail.tsx +++ b/stats/src/components/sessions/SessionDetail.tsx @@ -1,35 +1,37 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { - AreaChart, - Area, - LineChart, - Line, - XAxis, - YAxis, - Tooltip, - ResponsiveContainer, - ReferenceArea, - ReferenceLine, + Bar, + BarChart, CartesianGrid, Customized, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, } from 'recharts'; import { useSessionDetail } from '../../hooks/useSessions'; import { getStatsClient } from '../../hooks/useStatsApi'; -import type { KnownWordsTimelinePoint } from '../../hooks/useSessions'; import { CHART_THEME } from '../../lib/chart-theme'; +import { formatClockDuration, formatDuration, formatNumber } from '../../lib/formatters'; +import { + buildSessionDensity, + formatBucketSize, + type SessionDensity, + type SessionDensityBucket, +} from '../../lib/session-density'; import { buildSessionChartEvents, collectPendingSessionEventNoteIds, getSessionEventCardRequest, mergeSessionEventNoteInfos, + projectSessionMarkerLeftPx, resolveActiveSessionMarkerKey, type SessionChartMarker, type SessionEventNoteInfo, type SessionChartPlotArea, } from '../../lib/session-events'; -import { buildLookupRateDisplay } from '../../lib/yomitan-lookup'; import { getSessionDisplayWordCount } from '../../lib/session-word-count'; -import { EventType } from '../../types/stats'; +import { buildLookupRateDisplay } from '../../lib/yomitan-lookup'; import type { SessionEvent, SessionSummary } from '../../types/stats'; import { SessionEventOverlay } from './SessionEventOverlay'; @@ -37,15 +39,21 @@ interface SessionDetailProps { session: SessionSummary; } -const tooltipStyle = { - background: CHART_THEME.tooltipBg, - border: `1px solid ${CHART_THEME.tooltipBorder}`, - borderRadius: 6, - color: CHART_THEME.tooltipText, - fontSize: 11, -}; +const SERIES_COLORS = { + known: '#a6da95', + unknown: '#c6a0f6', + words: '#8aadf4', + lookup: '#b7bdf8', + card: '#f5bde6', +} as const; + +const CHART_HEIGHT = 150; function formatTime(ms: number): string { + return new Date(ms).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }); +} + +function formatTimeWithSeconds(ms: number): string { return new Date(ms).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', @@ -53,85 +61,7 @@ function formatTime(ms: number): string { }); } -type KnownWordsLineCounts = { - knownWordsSeen: number; - totalWordsSeen: number; -}; - -/** Build a lookup: linesSeen -> filtered known/total word counts */ -function buildKnownWordsLookup( - knownWordsTimeline: KnownWordsTimelinePoint[], -): Map { - const map = new Map(); - for (const pt of knownWordsTimeline) { - map.set(pt.linesSeen, { - knownWordsSeen: pt.knownWordsSeen, - totalWordsSeen: pt.totalWordsSeen, - }); - } - return map; -} - -/** For a given linesSeen value, find the closest filtered word counts (floor lookup). */ -function lookupKnownWordCounts( - map: Map, - linesSeen: number, -): KnownWordsLineCounts { - if (map.size === 0) return { knownWordsSeen: 0, totalWordsSeen: 0 }; - if (map.has(linesSeen)) return map.get(linesSeen)!; - let best = 0; - for (const k of map.keys()) { - if (k <= linesSeen && k > best) { - best = k; - } - } - return best > 0 ? map.get(best)! : { knownWordsSeen: 0, totalWordsSeen: 0 }; -} - -interface RatioChartPoint { - tsMs: number; - knownWords: number; - unknownWords: number; - totalWords: number; -} - -interface FallbackChartPoint { - tsMs: number; - totalWords: number; -} - -type TimelineEntry = { - sampleMs: number; - linesSeen: number; - tokensSeen: number; -}; - -export function buildKnownWordsRatioChartData( - sorted: TimelineEntry[], - knownWordsMap: Map, -): RatioChartPoint[] { - const chartData: RatioChartPoint[] = []; - for (const t of sorted) { - const counts = lookupKnownWordCounts(knownWordsMap, t.linesSeen); - const totalWords = counts.totalWordsSeen; - if (totalWords === 0) continue; - const knownWords = Math.min(counts.knownWordsSeen, totalWords); - const unknownWords = totalWords - knownWords; - chartData.push({ - tsMs: t.sampleMs, - knownWords, - unknownWords, - totalWords, - }); - } - return chartData; -} - -export function getKnownPctAxisMax(values: number[]): number { - const max = Math.max(0, ...values.filter((value) => Number.isFinite(value))); - return Math.min(100, Math.ceil((max + 5) / 10) * 10); -} - +/** Reads the plot offset recharts computed so the event lane can line up with the bars. */ function SessionChartOffsetProbe({ offset, onPlotAreaChange, @@ -160,18 +90,11 @@ export function SessionDetail({ session }: SessionDetailProps) { const [loadingNoteIds, setLoadingNoteIds] = useState>(new Set()); const pendingNoteIdsRef = useRef>(new Set()); - const sorted = [...timeline].reverse(); - const knownWordsMap = buildKnownWordsLookup(knownWordsTimeline); - const hasKnownWords = knownWordsMap.size > 0; - - const { cardEvents, yomitanLookupEvents, pauseRegions, markers } = - buildSessionChartEvents(events); - const lookupRate = buildLookupRateDisplay( - session.yomitanLookupCount, - getSessionDisplayWordCount(session), + const density = useMemo( + () => buildSessionDensity(timeline, knownWordsTimeline, events), + [timeline, knownWordsTimeline, events], ); - const pauseCount = events.filter((e) => e.eventType === EventType.PAUSE_START).length; - const cardEventCount = cardEvents.length; + const { yomitanLookupEvents, markers } = useMemo(() => buildSessionChartEvents(events), [events]); const activeMarkerKey = resolveActiveSessionMarkerKey(hoveredMarkerKey, pinnedMarkerKey); const activeMarker = useMemo( () => markers.find((marker) => marker.key === activeMarkerKey) ?? null, @@ -259,544 +182,378 @@ export function SessionDetail({ session }: SessionDetailProps) { void getStatsClient().ankiBrowse(noteId); }; - if (loading) return
Loading timeline...
; + if (loading) return
Loading session...
; if (error) return
Error: {error}
; - if (hasKnownWords) { - return ( - - ); - } - - return ( - - ); -} - -/* ── Ratio View (primary design) ────────────────────────────────── */ - -function RatioView({ - sorted, - knownWordsMap, - cardEvents, - yomitanLookupEvents, - pauseRegions, - markers, - hoveredMarkerKey, - onHoveredMarkerChange, - pinnedMarkerKey, - onPinnedMarkerChange, - noteInfos, - loadingNoteIds, - onOpenNote, - pauseCount, - cardEventCount, - lookupRate, - session, -}: { - sorted: TimelineEntry[]; - knownWordsMap: Map; - cardEvents: SessionEvent[]; - yomitanLookupEvents: SessionEvent[]; - pauseRegions: Array<{ startMs: number; endMs: number }>; - markers: SessionChartMarker[]; - hoveredMarkerKey: string | null; - onHoveredMarkerChange: (markerKey: string | null) => void; - pinnedMarkerKey: string | null; - onPinnedMarkerChange: (markerKey: string | null) => void; - noteInfos: Map; - loadingNoteIds: Set; - onOpenNote: (noteId: number) => void; - pauseCount: number; - cardEventCount: number; - lookupRate: ReturnType; - session: SessionSummary; -}) { - const [plotArea, setPlotArea] = useState(null); - const chartData = buildKnownWordsRatioChartData(sorted, knownWordsMap); - - if (chartData.length === 0) { - return
No word data for this session.
; - } - - const tsMin = chartData[0]!.tsMs; - const tsMax = chartData[chartData.length - 1]!.tsMs; - const finalTotal = chartData[chartData.length - 1]!.totalWords; - - const sparkData = chartData.map((d) => ({ tsMs: d.tsMs, totalWords: d.totalWords })); - - return ( -
- {/* ── Top: Percentage area chart ── */} -
- - - { - setPlotArea((prevPlotArea) => - prevPlotArea && - prevPlotArea.left === nextPlotArea.left && - prevPlotArea.width === nextPlotArea.width - ? prevPlotArea - : nextPlotArea, - ); - }} - /> - } - /> - - - - - - - - - - - - - - - `${v.toLocaleString()}`} - axisLine={false} - tickLine={false} - width={32} - /> - - { - const d = props.payload; - if (!d) return [_value, name]; - if (name === 'Known words') { - const knownPct = d.totalWords === 0 ? 0 : (d.knownWords / d.totalWords) * 100; - return [`${d.knownWords.toLocaleString()} (${knownPct.toFixed(1)}%)`, name]; - } - if (name === 'Unknown words') return [d.unknownWords.toLocaleString(), name]; - return [_value, name]; - }} - itemSorter={() => -1} - /> - - {/* Pause shaded regions */} - {pauseRegions.map((r, i) => ( - - ))} - - {/* Card mine markers */} - {cardEvents.map((e, i) => ( - - ))} - - {/* Yomitan lookup markers */} - {yomitanLookupEvents.map((e, i) => ( - - ))} - - - - - - -
- - {/* ── Bottom: Token accumulation sparkline ── */} -
- total words -
- - - - - - - -
- - {finalTotal.toLocaleString()} - -
- - {/* ── Stats bar ── */} - -
- ); -} - -/* ── Fallback View (no known words data) ────────────────────────── */ - -function FallbackView({ - sorted, - cardEvents, - yomitanLookupEvents, - pauseRegions, - markers, - hoveredMarkerKey, - onHoveredMarkerChange, - pinnedMarkerKey, - onPinnedMarkerChange, - noteInfos, - loadingNoteIds, - onOpenNote, - pauseCount, - cardEventCount, - lookupRate, - session, -}: { - sorted: TimelineEntry[]; - cardEvents: SessionEvent[]; - yomitanLookupEvents: SessionEvent[]; - pauseRegions: Array<{ startMs: number; endMs: number }>; - markers: SessionChartMarker[]; - hoveredMarkerKey: string | null; - onHoveredMarkerChange: (markerKey: string | null) => void; - pinnedMarkerKey: string | null; - onPinnedMarkerChange: (markerKey: string | null) => void; - noteInfos: Map; - loadingNoteIds: Set; - onOpenNote: (noteId: number) => void; - pauseCount: number; - cardEventCount: number; - lookupRate: ReturnType; - session: SessionSummary; -}) { - const [plotArea, setPlotArea] = useState(null); - const chartData: FallbackChartPoint[] = []; - for (const t of sorted) { - const totalWords = getSessionDisplayWordCount(t); - if (totalWords === 0) continue; - chartData.push({ tsMs: t.sampleMs, totalWords }); - } - - if (chartData.length === 0) { - return
No word data for this session.
; - } - - const tsMin = chartData[0]!.tsMs; - const tsMax = chartData[chartData.length - 1]!.tsMs; - return (
-
- - - { - setPlotArea((prevPlotArea) => - prevPlotArea && - prevPlotArea.left === nextPlotArea.left && - prevPlotArea.width === nextPlotArea.width - ? prevPlotArea - : nextPlotArea, - ); - }} - /> - } - /> - - - [`${value.toLocaleString()}`, 'Total words']} - /> - - {pauseRegions.map((r, i) => ( - - ))} - - {cardEvents.map((e, i) => ( - - ))} - {yomitanLookupEvents.map((e, i) => ( - - ))} - - - - - + {density.buckets.length === 0 ? ( +
No activity samples for this session.
+ ) : ( + + )} +
+ ); +} + +/* ── Stat tiles ─────────────────────────────────────────────────── */ + +export function buildSessionStatTiles(session: SessionSummary): Array<{ + key: string; + label: string; + value: string; + detail: string; + colorClass: string; +}> { + const words = getSessionDisplayWordCount(session); + const activeMin = session.activeWatchedMs / 60_000; + const wordsPerMin = activeMin > 0 ? words / activeMin : 0; + const linesPerMin = activeMin > 0 ? session.linesSeen / activeMin : 0; + const pausedMs = Math.max(0, session.totalWatchedMs - session.activeWatchedMs); + const pausedPct = + session.totalWatchedMs > 0 ? Math.round((pausedMs / session.totalWatchedMs) * 100) : 0; + const lookupRate = buildLookupRateDisplay(session.yomitanLookupCount, words); + const hasKnownRate = session.knownWordsSeen > 0 && words > 0; + + return [ + { + key: 'pace', + label: 'Pace', + value: activeMin > 0 ? `${wordsPerMin.toFixed(0)} words/min` : '—', + detail: activeMin > 0 ? `${linesPerMin.toFixed(1)} lines/min` : 'no active time', + colorClass: 'text-ctp-blue', + }, + { + key: 'lines', + label: 'Lines', + value: formatNumber(session.linesSeen), + detail: `${formatNumber(words)} words`, + colorClass: 'text-ctp-mauve', + }, + { + key: 'known', + label: 'Known', + value: hasKnownRate ? `${session.knownWordRate.toFixed(0)}%` : '—', + detail: hasKnownRate + ? `${formatNumber(session.knownWordsSeen)} known words` + : 'no known-word data', + colorClass: 'text-ctp-green', + }, + { + key: 'lookups', + label: 'Lookups', + value: formatNumber(session.yomitanLookupCount), + detail: lookupRate ? lookupRate.longValue : 'Yomitan lookups', + colorClass: 'text-ctp-lavender', + }, + { + key: 'paused', + label: 'Paused', + value: pausedMs > 0 ? formatClockDuration(pausedMs) : 'none', + detail: `${pausedPct}% of ${formatDuration(session.totalWatchedMs)} wall time`, + colorClass: 'text-ctp-peach', + }, + ]; +} + +function SessionStatTiles({ session }: { session: SessionSummary }) { + const tiles = buildSessionStatTiles(session); + return ( +
+ {tiles.map((tile) => ( +
+
{tile.label}
+
+ {tile.value} +
+
{tile.detail}
+
+ ))} +
+ ); +} + +/* ── Density chart + event lane ─────────────────────────────────── */ + +interface SessionDensityChartProps { + density: SessionDensity; + lookupEvents: SessionEvent[]; + markers: SessionChartMarker[]; + hoveredMarkerKey: string | null; + onHoveredMarkerChange: (markerKey: string | null) => void; + pinnedMarkerKey: string | null; + onPinnedMarkerChange: (markerKey: string | null) => void; + noteInfos: Map; + loadingNoteIds: Set; + onOpenNote: (noteId: number) => void; +} + +/** Roughly five evenly spaced tick indexes, always including the first bucket. */ +export function pickDensityTickIndexes(bucketCount: number, maxTicks = 5): number[] { + if (bucketCount <= 0) return []; + const step = Math.max(1, Math.ceil(bucketCount / maxTicks)); + const ticks: number[] = []; + for (let index = 0; index < bucketCount; index += step) { + ticks.push(index); + } + return ticks; +} + +function DensityTooltip({ + active, + payload, + hasKnownWords, +}: { + active?: boolean; + payload?: Array<{ payload?: SessionDensityBucket }>; + hasKnownWords: boolean; +}) { + const bucket = payload?.[0]?.payload; + if (!active || !bucket) return null; + const knownPct = bucket.words > 0 ? Math.round((bucket.knownWords / bucket.words) * 100) : 0; + + return ( +
+
+ {formatTimeWithSeconds(bucket.startMs)} – {formatTimeWithSeconds(bucket.endMs)} +
+
+ {formatNumber(bucket.words)} {bucket.words === 1 ? 'word' : 'words'} +
+ {hasKnownWords && bucket.words > 0 ? ( +
+ + {formatNumber(bucket.knownWords)} known + + ({knownPct}%) + · + + {formatNumber(bucket.unknownWords)} unknown + +
+ ) : null} + {bucket.lookups > 0 ? ( +
+ {bucket.lookups} {bucket.lookups === 1 ? 'lookup' : 'lookups'} +
+ ) : null} + {bucket.cards > 0 ? ( +
+ {bucket.cards} {bucket.cards === 1 ? 'card' : 'cards'} mined +
+ ) : null} +
+ ); +} + +function SessionDensityChart({ + density, + lookupEvents, + markers, + hoveredMarkerKey, + onHoveredMarkerChange, + pinnedMarkerKey, + onPinnedMarkerChange, + noteInfos, + loadingNoteIds, + onOpenNote, +}: SessionDensityChartProps) { + const [plotArea, setPlotArea] = useState(null); + const { buckets, startMs, endMs, hasKnownWords } = density; + const tickIndexes = pickDensityTickIndexes(buckets.length); + const hasLane = lookupEvents.length > 0 || markers.length > 0; + + const laneLeft = (tsMs: number): string => { + if (plotArea) { + return `${projectSessionMarkerLeftPx({ + anchorTsMs: tsMs, + tsMin: startMs, + tsMax: endMs, + plotLeftPx: plotArea.left, + plotWidthPx: plotArea.width, + })}px`; + } + const ratio = endMs > startMs ? (tsMs - startMs) / (endMs - startMs) : 0.5; + return `${Math.max(0, Math.min(100, ratio * 100))}%`; + }; + + return ( +
+
+
Reading density
+
+ words per {formatBucketSize(density.bucketMs)} +
- + + + { + setPlotArea((prevPlotArea) => + prevPlotArea && + prevPlotArea.left === nextPlotArea.left && + prevPlotArea.width === nextPlotArea.width + ? prevPlotArea + : nextPlotArea, + ); + }} + /> + } + /> + + formatTime(buckets[index]?.startMs ?? startMs)} + tick={{ fontSize: 9, fill: CHART_THEME.tick }} + axisLine={false} + tickLine={false} + /> + + } + isAnimationActive={false} + /> + {/* recharts only registers direct Bar children, so no fragment wrapper here */} + {hasKnownWords ? ( + + ) : null} + {hasKnownWords ? ( + + ) : ( + + )} + + + + {hasLane ? ( +
+
+ {lookupEvents.map((event, index) => ( + + ))} + +
+ ) : null} + +
+ {hasKnownWords ? ( + <> + + + + ) : ( + + )} + {lookupEvents.length > 0 ? ( + + + Yomitan lookup + + ) : null} + {markers.length > 0 ? ( + + {'⛏'} + Card mined (click for note) + + ) : null} +
); } -/* ── Stats Bar ──────────────────────────────────────────────────── */ - -function StatsBar({ - hasKnownWords, - pauseCount, - cardEventCount, - session, - lookupRate, -}: { - hasKnownWords: boolean; - pauseCount: number; - cardEventCount: number; - session: SessionSummary; - lookupRate: ReturnType; -}) { +function LegendSwatch({ color, label }: { color: string; label: string }) { return ( -
- {/* Group 1: Legend */} - {hasKnownWords && ( - <> - - - Known - - - - Unknown - - | - - )} - - {/* Group 2: Playback stats */} - {pauseCount > 0 && ( - - {pauseCount} pause - {pauseCount !== 1 ? 's' : ''} - - )} - {pauseCount > 0 && |} - - {/* Group 3: Learning events */} - - - - {session.yomitanLookupCount} Yomitan lookup - {session.yomitanLookupCount !== 1 ? 's' : ''} - - - {lookupRate && ( - - lookup rate: {lookupRate.shortValue}{' '} - ({lookupRate.longValue}) - - )} - - {'\u26CF'} - - {Math.max(cardEventCount, session.cardsMined)} card - {Math.max(cardEventCount, session.cardsMined) !== 1 ? 's' : ''} mined - - -
+ + + {label} + ); } diff --git a/stats/src/components/sessions/SessionEventOverlay.tsx b/stats/src/components/sessions/SessionEventOverlay.tsx index e1eee596..7d6a0aab 100644 --- a/stats/src/components/sessions/SessionEventOverlay.tsx +++ b/stats/src/components/sessions/SessionEventOverlay.tsx @@ -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 (
onHoveredMarkerChange(marker.key)} onMouseLeave={(event) => @@ -160,13 +147,14 @@ export function SessionEventOverlay({
{isActive ? (
-
- {marker.kind === 'pause' && 'Paused'} - {marker.kind === 'card' && 'Card mined'} -
+
Card mined
{formatEventTime(marker.eventTsMs)}
@@ -64,78 +57,65 @@ export function SessionEventPopover({ × ) : null} -
- {marker.kind === 'pause' && '||'} - {marker.kind === 'card' && '\u26CF'} -
+
{'\u26CF'}
- {marker.kind === 'pause' && ( -
- Duration: {formatEventSeconds(marker.durationMs)} +
+
+ +{marker.cardsDelta} {marker.cardsDelta === 1 ? 'card' : 'cards'}
- )} - - {marker.kind === 'card' && ( -
-
- +{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 }; }