fix(stats): align session event popovers with chart plot area

This commit is contained in:
2026-03-17 23:56:58 -07:00
parent e694963426
commit a5b1c0509d
5 changed files with 953 additions and 177 deletions

View File

@@ -0,0 +1,62 @@
---
id: TASK-193
title: Fix session chart event popup position drift
status: Done
assignee:
- Codex
created_date: '2026-03-17 23:55'
updated_date: '2026-03-17 23:59'
labels:
- stats
- ui
- bug
milestone: m-1
dependencies: []
references:
- stats/src/components/sessions/SessionDetail.tsx
- stats/src/components/sessions/SessionEventOverlay.tsx
- stats/src/lib/session-events.ts
priority: medium
ordinal: 105600
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Fix the session timeline event popup trigger positions so hover markers stay aligned with the underlying chart event lines across the full visible time range.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Event popup triggers stay horizontally aligned with chart event lines from session start through session end.
- [x] #2 Alignment logic uses the rendered chart plot area rather than guessed container percentages.
- [x] #3 Regression coverage locks the marker-position projection math.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Add a failing regression test for marker-position projection with chart offsets.
2. Capture the rendered plot box from Recharts and pass it into the overlay.
3. Position overlay markers in plot-area pixels, rerun targeted stats verification, then record the result.
<!-- SECTION:PLAN:END -->
## Outcome
<!-- SECTION:OUTCOME:BEGIN -->
Completed. Session event hover markers now read the actual Recharts plot-area offset and width, then project marker X positions into plot-area pixels instead of full-container percentages. That keeps popup triggers aligned with the underlying reference lines across long session timelines.
Verification:
- `bun test stats/src/lib/session-events.test.ts stats/src/lib/session-detail.test.tsx stats/src/components/sessions/SessionEventPopover.test.tsx`
- `cd stats && bun run build`
- `bun x prettier --check 'stats/src/components/sessions/SessionDetail.tsx' 'stats/src/components/sessions/SessionEventOverlay.tsx' 'stats/src/lib/session-events.ts' 'stats/src/lib/session-events.test.ts' 'backlog/tasks/task-193 - Fix-session-chart-event-popup-position-drift.md'`
- `bun run typecheck:stats` still fails on pre-existing unrelated errors in `src/components/anime/AnilistSelector.tsx`, `src/components/library/LibraryTab.tsx`, `src/lib/reading-utils.test.ts`, `src/lib/reading-utils.ts`, `src/lib/vocabulary-tab.test.ts`, and `src/lib/yomitan-lookup.test.tsx`
<!-- SECTION:OUTCOME:END -->

View File

@@ -11,6 +11,7 @@ import {
ReferenceArea, ReferenceArea,
ReferenceLine, ReferenceLine,
CartesianGrid, CartesianGrid,
Customized,
} from 'recharts'; } from 'recharts';
import { useSessionDetail } from '../../hooks/useSessions'; import { useSessionDetail } from '../../hooks/useSessions';
import { getStatsClient } from '../../hooks/useStatsApi'; import { getStatsClient } from '../../hooks/useStatsApi';
@@ -18,8 +19,11 @@ import type { KnownWordsTimelinePoint } from '../../hooks/useSessions';
import { CHART_THEME } from '../../lib/chart-theme'; import { CHART_THEME } from '../../lib/chart-theme';
import { import {
buildSessionChartEvents, buildSessionChartEvents,
extractSessionEventNoteInfo,
resolveActiveSessionMarkerKey,
type SessionChartMarker, type SessionChartMarker,
type SessionEventNoteInfo, type SessionEventNoteInfo,
type SessionChartPlotArea,
} from '../../lib/session-events'; } from '../../lib/session-events';
import { buildLookupRateDisplay } from '../../lib/yomitan-lookup'; import { buildLookupRateDisplay } from '../../lib/yomitan-lookup';
import { getSessionDisplayWordCount } from '../../lib/session-word-count'; import { getSessionDisplayWordCount } from '../../lib/session-word-count';
@@ -69,19 +73,6 @@ function lookupKnownWords(map: Map<number, number>, linesSeen: number): number {
return best > 0 ? map.get(best)! : 0; return best > 0 ? map.get(best)! : 0;
} }
function extractNoteExpression(note: {
noteId: number;
fields: Record<string, { value: string }>;
}): SessionEventNoteInfo {
const expression =
note.fields?.Expression?.value ??
note.fields?.expression?.value ??
note.fields?.Word?.value ??
note.fields?.word?.value ??
'';
return { noteId: note.noteId, expression };
}
interface RatioChartPoint { interface RatioChartPoint {
tsMs: number; tsMs: number;
knownPct: number; knownPct: number;
@@ -102,11 +93,30 @@ type TimelineEntry = {
tokensSeen: number; tokensSeen: number;
}; };
function SessionChartOffsetProbe({
offset,
onPlotAreaChange,
}: {
offset?: { left?: number; width?: number };
onPlotAreaChange: (plotArea: SessionChartPlotArea) => void;
}) {
useEffect(() => {
if (!offset) return;
const { left, width } = offset;
if (typeof left !== 'number' || !Number.isFinite(left)) return;
if (typeof width !== 'number' || !Number.isFinite(width)) return;
onPlotAreaChange({ left, width });
}, [offset?.left, offset?.width, onPlotAreaChange]);
return null;
}
export function SessionDetail({ session }: SessionDetailProps) { export function SessionDetail({ session }: SessionDetailProps) {
const { timeline, events, knownWordsTimeline, loading, error } = useSessionDetail( const { timeline, events, knownWordsTimeline, loading, error } = useSessionDetail(
session.sessionId, session.sessionId,
); );
const [activeMarkerKey, setActiveMarkerKey] = useState<string | null>(null); const [hoveredMarkerKey, setHoveredMarkerKey] = useState<string | null>(null);
const [pinnedMarkerKey, setPinnedMarkerKey] = useState<string | null>(null);
const [noteInfos, setNoteInfos] = useState<Map<number, SessionEventNoteInfo>>(new Map()); const [noteInfos, setNoteInfos] = useState<Map<number, SessionEventNoteInfo>>(new Map());
const [loadingNoteIds, setLoadingNoteIds] = useState<Set<number>>(new Set()); const [loadingNoteIds, setLoadingNoteIds] = useState<Set<number>>(new Set());
const requestedNoteIdsRef = useRef<Set<number>>(new Set()); const requestedNoteIdsRef = useRef<Set<number>>(new Set());
@@ -124,6 +134,7 @@ export function SessionDetail({ session }: SessionDetailProps) {
const pauseCount = events.filter((e) => e.eventType === EventType.PAUSE_START).length; const pauseCount = events.filter((e) => e.eventType === EventType.PAUSE_START).length;
const seekCount = seekEvents.length; const seekCount = seekEvents.length;
const cardEventCount = cardEvents.length; const cardEventCount = cardEvents.length;
const activeMarkerKey = resolveActiveSessionMarkerKey(hoveredMarkerKey, pinnedMarkerKey);
const activeMarker = useMemo<SessionChartMarker | null>( const activeMarker = useMemo<SessionChartMarker | null>(
() => markers.find((marker) => marker.key === activeMarkerKey) ?? null, () => markers.find((marker) => marker.key === activeMarkerKey) ?? null,
[markers, activeMarkerKey], [markers, activeMarkerKey],
@@ -161,7 +172,8 @@ export function SessionDetail({ session }: SessionDetailProps) {
setNoteInfos((prev) => { setNoteInfos((prev) => {
const next = new Map(prev); const next = new Map(prev);
for (const note of notes) { for (const note of notes) {
const info = extractNoteExpression(note); const info = extractSessionEventNoteInfo(note);
if (!info) continue;
next.set(info.noteId, info); next.set(info.noteId, info);
} }
return next; return next;
@@ -205,8 +217,10 @@ export function SessionDetail({ session }: SessionDetailProps) {
yomitanLookupEvents={yomitanLookupEvents} yomitanLookupEvents={yomitanLookupEvents}
pauseRegions={pauseRegions} pauseRegions={pauseRegions}
markers={markers} markers={markers}
activeMarkerKey={activeMarkerKey} hoveredMarkerKey={hoveredMarkerKey}
onActiveMarkerChange={setActiveMarkerKey} onHoveredMarkerChange={setHoveredMarkerKey}
pinnedMarkerKey={pinnedMarkerKey}
onPinnedMarkerChange={setPinnedMarkerKey}
noteInfos={noteInfos} noteInfos={noteInfos}
loadingNoteIds={loadingNoteIds} loadingNoteIds={loadingNoteIds}
onOpenNote={handleOpenNote} onOpenNote={handleOpenNote}
@@ -227,8 +241,10 @@ export function SessionDetail({ session }: SessionDetailProps) {
yomitanLookupEvents={yomitanLookupEvents} yomitanLookupEvents={yomitanLookupEvents}
pauseRegions={pauseRegions} pauseRegions={pauseRegions}
markers={markers} markers={markers}
activeMarkerKey={activeMarkerKey} hoveredMarkerKey={hoveredMarkerKey}
onActiveMarkerChange={setActiveMarkerKey} onHoveredMarkerChange={setHoveredMarkerKey}
pinnedMarkerKey={pinnedMarkerKey}
onPinnedMarkerChange={setPinnedMarkerKey}
noteInfos={noteInfos} noteInfos={noteInfos}
loadingNoteIds={loadingNoteIds} loadingNoteIds={loadingNoteIds}
onOpenNote={handleOpenNote} onOpenNote={handleOpenNote}
@@ -251,8 +267,10 @@ function RatioView({
yomitanLookupEvents, yomitanLookupEvents,
pauseRegions, pauseRegions,
markers, markers,
activeMarkerKey, hoveredMarkerKey,
onActiveMarkerChange, onHoveredMarkerChange,
pinnedMarkerKey,
onPinnedMarkerChange,
noteInfos, noteInfos,
loadingNoteIds, loadingNoteIds,
onOpenNote, onOpenNote,
@@ -269,8 +287,10 @@ function RatioView({
yomitanLookupEvents: SessionEvent[]; yomitanLookupEvents: SessionEvent[];
pauseRegions: Array<{ startMs: number; endMs: number }>; pauseRegions: Array<{ startMs: number; endMs: number }>;
markers: SessionChartMarker[]; markers: SessionChartMarker[];
activeMarkerKey: string | null; hoveredMarkerKey: string | null;
onActiveMarkerChange: (markerKey: string | null) => void; onHoveredMarkerChange: (markerKey: string | null) => void;
pinnedMarkerKey: string | null;
onPinnedMarkerChange: (markerKey: string | null) => void;
noteInfos: Map<number, SessionEventNoteInfo>; noteInfos: Map<number, SessionEventNoteInfo>;
loadingNoteIds: Set<number>; loadingNoteIds: Set<number>;
onOpenNote: (noteId: number) => void; onOpenNote: (noteId: number) => void;
@@ -280,6 +300,7 @@ function RatioView({
lookupRate: ReturnType<typeof buildLookupRateDisplay>; lookupRate: ReturnType<typeof buildLookupRateDisplay>;
session: SessionSummary; session: SessionSummary;
}) { }) {
const [plotArea, setPlotArea] = useState<SessionChartPlotArea | null>(null);
const chartData: RatioChartPoint[] = []; const chartData: RatioChartPoint[] = [];
for (const t of sorted) { for (const t of sorted) {
const totalWords = getSessionDisplayWordCount(t); const totalWords = getSessionDisplayWordCount(t);
@@ -313,84 +334,99 @@ function RatioView({
<div className="relative"> <div className="relative">
<ResponsiveContainer width="100%" height={130}> <ResponsiveContainer width="100%" height={130}>
<AreaChart data={chartData}> <AreaChart data={chartData}>
<defs> <Customized
<linearGradient id={`knownGrad-${session.sessionId}`} x1="0" y1="1" x2="0" y2="0"> component={
<stop offset="0%" stopColor="#a6da95" stopOpacity={0.4} /> <SessionChartOffsetProbe
<stop offset="100%" stopColor="#a6da95" stopOpacity={0.15} /> onPlotAreaChange={(nextPlotArea) => {
</linearGradient> setPlotArea((prevPlotArea) =>
<linearGradient id={`unknownGrad-${session.sessionId}`} x1="0" y1="0" x2="0" y2="1"> prevPlotArea &&
<stop offset="0%" stopColor="#c6a0f6" stopOpacity={0.25} /> prevPlotArea.left === nextPlotArea.left &&
<stop offset="100%" stopColor="#c6a0f6" stopOpacity={0.08} /> prevPlotArea.width === nextPlotArea.width
</linearGradient> ? prevPlotArea
</defs> : nextPlotArea,
);
<CartesianGrid }}
horizontal />
vertical={false} }
stroke="#494d64"
strokeDasharray="4 4"
strokeOpacity={0.4}
/>
<XAxis
dataKey="tsMs"
type="number"
domain={[tsMin, tsMax]}
tick={{ fontSize: 9, fill: CHART_THEME.tick }}
axisLine={false}
tickLine={false}
tickFormatter={formatTime}
interval="preserveStartEnd"
/>
<YAxis
yAxisId="pct"
orientation="right"
domain={[0, 100]}
ticks={[0, 50, 100]}
tick={{ fontSize: 9, fill: CHART_THEME.tick }}
tickFormatter={(v: number) => `${v}%`}
axisLine={false}
tickLine={false}
width={32}
/>
<Tooltip
contentStyle={tooltipStyle}
labelFormatter={formatTime}
formatter={(_value: number, name: string, props: { payload?: RatioChartPoint }) => {
const d = props.payload;
if (!d) return [_value, name];
if (name === 'Known')
return [`${d.knownWords.toLocaleString()} (${d.knownPct.toFixed(1)}%)`, 'Known'];
if (name === 'Unknown')
return [
`${d.unknownWords.toLocaleString()} (${d.unknownPct.toFixed(1)}%)`,
'Unknown',
];
return [_value, name];
}}
itemSorter={() => -1}
/>
{/* Pause shaded regions */}
{pauseRegions.map((r, i) => (
<ReferenceArea
key={`pause-${i}`}
yAxisId="pct"
x1={r.startMs}
x2={r.endMs}
y1={0}
y2={100}
fill="#f5a97f"
fillOpacity={0.15}
stroke="#f5a97f"
strokeOpacity={0.4}
strokeDasharray="3 3"
strokeWidth={1}
/> />
))} <defs>
<linearGradient id={`knownGrad-${session.sessionId}`} x1="0" y1="1" x2="0" y2="0">
<stop offset="0%" stopColor="#a6da95" stopOpacity={0.4} />
<stop offset="100%" stopColor="#a6da95" stopOpacity={0.15} />
</linearGradient>
<linearGradient id={`unknownGrad-${session.sessionId}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#c6a0f6" stopOpacity={0.25} />
<stop offset="100%" stopColor="#c6a0f6" stopOpacity={0.08} />
</linearGradient>
</defs>
{/* Card mine markers */} <CartesianGrid
horizontal
vertical={false}
stroke="#494d64"
strokeDasharray="4 4"
strokeOpacity={0.4}
/>
<XAxis
dataKey="tsMs"
type="number"
domain={[tsMin, tsMax]}
tick={{ fontSize: 9, fill: CHART_THEME.tick }}
axisLine={false}
tickLine={false}
tickFormatter={formatTime}
interval="preserveStartEnd"
/>
<YAxis
yAxisId="pct"
orientation="right"
domain={[0, 100]}
ticks={[0, 50, 100]}
tick={{ fontSize: 9, fill: CHART_THEME.tick }}
tickFormatter={(v: number) => `${v}%`}
axisLine={false}
tickLine={false}
width={32}
/>
<Tooltip
contentStyle={tooltipStyle}
labelFormatter={formatTime}
formatter={(_value: number, name: string, props: { payload?: RatioChartPoint }) => {
const d = props.payload;
if (!d) return [_value, name];
if (name === 'Known')
return [`${d.knownWords.toLocaleString()} (${d.knownPct.toFixed(1)}%)`, 'Known'];
if (name === 'Unknown')
return [
`${d.unknownWords.toLocaleString()} (${d.unknownPct.toFixed(1)}%)`,
'Unknown',
];
return [_value, name];
}}
itemSorter={() => -1}
/>
{/* Pause shaded regions */}
{pauseRegions.map((r, i) => (
<ReferenceArea
key={`pause-${i}`}
yAxisId="pct"
x1={r.startMs}
x2={r.endMs}
y1={0}
y2={100}
fill="#f5a97f"
fillOpacity={0.15}
stroke="#f5a97f"
strokeOpacity={0.4}
strokeDasharray="3 3"
strokeWidth={1}
/>
))}
{/* Card mine markers */}
{cardEvents.map((e, i) => ( {cardEvents.map((e, i) => (
<ReferenceLine <ReferenceLine
key={`card-${i}`} key={`card-${i}`}
@@ -418,7 +454,7 @@ function RatioView({
); );
})} })}
{/* Yomitan lookup markers */} {/* Yomitan lookup markers */}
{yomitanLookupEvents.map((e, i) => ( {yomitanLookupEvents.map((e, i) => (
<ReferenceLine <ReferenceLine
key={`yomitan-${i}`} key={`yomitan-${i}`}
@@ -431,38 +467,41 @@ function RatioView({
/> />
))} ))}
<Area <Area
yAxisId="pct" yAxisId="pct"
dataKey="knownPct" dataKey="knownPct"
stackId="ratio" stackId="ratio"
stroke="#a6da95" stroke="#a6da95"
strokeWidth={1.5} strokeWidth={1.5}
fill={`url(#knownGrad-${session.sessionId})`} fill={`url(#knownGrad-${session.sessionId})`}
name="Known" name="Known"
type="monotone" type="monotone"
dot={false} dot={false}
activeDot={{ r: 3, fill: '#a6da95', stroke: '#1e2030', strokeWidth: 1 }} activeDot={{ r: 3, fill: '#a6da95', stroke: '#1e2030', strokeWidth: 1 }}
isAnimationActive={false} isAnimationActive={false}
/> />
<Area <Area
yAxisId="pct" yAxisId="pct"
dataKey="unknownPct" dataKey="unknownPct"
stackId="ratio" stackId="ratio"
stroke="#c6a0f6" stroke="#c6a0f6"
strokeWidth={0} strokeWidth={0}
fill={`url(#unknownGrad-${session.sessionId})`} fill={`url(#unknownGrad-${session.sessionId})`}
name="Unknown" name="Unknown"
type="monotone" type="monotone"
isAnimationActive={false} isAnimationActive={false}
/> />
</AreaChart> </AreaChart>
</ResponsiveContainer> </ResponsiveContainer>
<SessionEventOverlay <SessionEventOverlay
markers={markers} markers={markers}
tsMin={tsMin} tsMin={tsMin}
tsMax={tsMax} tsMax={tsMax}
activeMarkerKey={activeMarkerKey} plotArea={plotArea}
onActiveMarkerChange={onActiveMarkerChange} hoveredMarkerKey={hoveredMarkerKey}
onHoveredMarkerChange={onHoveredMarkerChange}
pinnedMarkerKey={pinnedMarkerKey}
onPinnedMarkerChange={onPinnedMarkerChange}
noteInfos={noteInfos} noteInfos={noteInfos}
loadingNoteIds={loadingNoteIds} loadingNoteIds={loadingNoteIds}
onOpenNote={onOpenNote} onOpenNote={onOpenNote}
@@ -516,8 +555,10 @@ function FallbackView({
yomitanLookupEvents, yomitanLookupEvents,
pauseRegions, pauseRegions,
markers, markers,
activeMarkerKey, hoveredMarkerKey,
onActiveMarkerChange, onHoveredMarkerChange,
pinnedMarkerKey,
onPinnedMarkerChange,
noteInfos, noteInfos,
loadingNoteIds, loadingNoteIds,
onOpenNote, onOpenNote,
@@ -533,8 +574,10 @@ function FallbackView({
yomitanLookupEvents: SessionEvent[]; yomitanLookupEvents: SessionEvent[];
pauseRegions: Array<{ startMs: number; endMs: number }>; pauseRegions: Array<{ startMs: number; endMs: number }>;
markers: SessionChartMarker[]; markers: SessionChartMarker[];
activeMarkerKey: string | null; hoveredMarkerKey: string | null;
onActiveMarkerChange: (markerKey: string | null) => void; onHoveredMarkerChange: (markerKey: string | null) => void;
pinnedMarkerKey: string | null;
onPinnedMarkerChange: (markerKey: string | null) => void;
noteInfos: Map<number, SessionEventNoteInfo>; noteInfos: Map<number, SessionEventNoteInfo>;
loadingNoteIds: Set<number>; loadingNoteIds: Set<number>;
onOpenNote: (noteId: number) => void; onOpenNote: (noteId: number) => void;
@@ -544,6 +587,7 @@ function FallbackView({
lookupRate: ReturnType<typeof buildLookupRateDisplay>; lookupRate: ReturnType<typeof buildLookupRateDisplay>;
session: SessionSummary; session: SessionSummary;
}) { }) {
const [plotArea, setPlotArea] = useState<SessionChartPlotArea | null>(null);
const chartData: FallbackChartPoint[] = []; const chartData: FallbackChartPoint[] = [];
for (const t of sorted) { for (const t of sorted) {
const totalWords = getSessionDisplayWordCount(t); const totalWords = getSessionDisplayWordCount(t);
@@ -563,42 +607,57 @@ function FallbackView({
<div className="relative"> <div className="relative">
<ResponsiveContainer width="100%" height={130}> <ResponsiveContainer width="100%" height={130}>
<LineChart data={chartData}> <LineChart data={chartData}>
<XAxis <Customized
dataKey="tsMs" component={
type="number" <SessionChartOffsetProbe
domain={[tsMin, tsMax]} onPlotAreaChange={(nextPlotArea) => {
tick={{ fontSize: 9, fill: CHART_THEME.tick }} setPlotArea((prevPlotArea) =>
axisLine={false} prevPlotArea &&
tickLine={false} prevPlotArea.left === nextPlotArea.left &&
tickFormatter={formatTime} prevPlotArea.width === nextPlotArea.width
interval="preserveStartEnd" ? prevPlotArea
/> : nextPlotArea,
<YAxis );
tick={{ fontSize: 9, fill: CHART_THEME.tick }} }}
axisLine={false} />
tickLine={false} }
width={30}
allowDecimals={false}
/>
<Tooltip
contentStyle={tooltipStyle}
labelFormatter={formatTime}
formatter={(value: number) => [`${value.toLocaleString()}`, 'Total tokens']}
/>
{pauseRegions.map((r, i) => (
<ReferenceArea
key={`pause-${i}`}
x1={r.startMs}
x2={r.endMs}
fill="#f5a97f"
fillOpacity={0.15}
stroke="#f5a97f"
strokeOpacity={0.4}
strokeDasharray="3 3"
strokeWidth={1}
/> />
))} <XAxis
dataKey="tsMs"
type="number"
domain={[tsMin, tsMax]}
tick={{ fontSize: 9, fill: CHART_THEME.tick }}
axisLine={false}
tickLine={false}
tickFormatter={formatTime}
interval="preserveStartEnd"
/>
<YAxis
tick={{ fontSize: 9, fill: CHART_THEME.tick }}
axisLine={false}
tickLine={false}
width={30}
allowDecimals={false}
/>
<Tooltip
contentStyle={tooltipStyle}
labelFormatter={formatTime}
formatter={(value: number) => [`${value.toLocaleString()}`, 'Total tokens']}
/>
{pauseRegions.map((r, i) => (
<ReferenceArea
key={`pause-${i}`}
x1={r.startMs}
x2={r.endMs}
fill="#f5a97f"
fillOpacity={0.15}
stroke="#f5a97f"
strokeOpacity={0.4}
strokeDasharray="3 3"
strokeWidth={1}
/>
))}
{cardEvents.map((e, i) => ( {cardEvents.map((e, i) => (
<ReferenceLine <ReferenceLine
@@ -634,24 +693,27 @@ function FallbackView({
/> />
))} ))}
<Line <Line
dataKey="totalWords" dataKey="totalWords"
stroke="#8aadf4" stroke="#8aadf4"
strokeWidth={1.5} strokeWidth={1.5}
dot={false} dot={false}
activeDot={{ r: 3, fill: '#8aadf4', stroke: '#1e2030', strokeWidth: 1 }} activeDot={{ r: 3, fill: '#8aadf4', stroke: '#1e2030', strokeWidth: 1 }}
name="Total tokens" name="Total tokens"
type="monotone" type="monotone"
isAnimationActive={false} isAnimationActive={false}
/> />
</LineChart> </LineChart>
</ResponsiveContainer> </ResponsiveContainer>
<SessionEventOverlay <SessionEventOverlay
markers={markers} markers={markers}
tsMin={tsMin} tsMin={tsMin}
tsMax={tsMax} tsMax={tsMax}
activeMarkerKey={activeMarkerKey} plotArea={plotArea}
onActiveMarkerChange={onActiveMarkerChange} hoveredMarkerKey={hoveredMarkerKey}
onHoveredMarkerChange={onHoveredMarkerChange}
pinnedMarkerKey={pinnedMarkerKey}
onPinnedMarkerChange={onPinnedMarkerChange}
noteInfos={noteInfos} noteInfos={noteInfos}
loadingNoteIds={loadingNoteIds} loadingNoteIds={loadingNoteIds}
onOpenNote={onOpenNote} onOpenNote={onOpenNote}

View File

@@ -0,0 +1,219 @@
import { useEffect, useRef, type FocusEvent, type MouseEvent } from 'react';
import {
projectSessionMarkerLeftPx,
resolveActiveSessionMarkerKey,
togglePinnedSessionMarkerKey,
type SessionChartMarker,
type SessionEventNoteInfo,
type SessionChartPlotArea,
} from '../../lib/session-events';
import { SessionEventPopover } from './SessionEventPopover';
interface SessionEventOverlayProps {
markers: SessionChartMarker[];
tsMin: number;
tsMax: number;
plotArea: SessionChartPlotArea | null;
hoveredMarkerKey: string | null;
onHoveredMarkerChange: (markerKey: string | null) => void;
pinnedMarkerKey: string | null;
onPinnedMarkerChange: (markerKey: string | null) => void;
noteInfos: Map<number, SessionEventNoteInfo>;
loadingNoteIds: Set<number>;
onOpenNote: (noteId: number) => void;
}
function toPercent(tsMs: number, tsMin: number, tsMax: number): number {
if (tsMax <= tsMin) return 50;
const ratio = ((tsMs - tsMin) / (tsMax - tsMin)) * 100;
return Math.max(0, Math.min(100, ratio));
}
function markerLabel(marker: SessionChartMarker): string {
switch (marker.kind) {
case 'pause':
return '||';
case 'seek':
return marker.direction === 'backward' ? '<<' : '>>';
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 'seek':
return marker.direction === 'backward'
? { border: '#f5bde6', bg: 'rgba(245,189,230,0.16)', text: '#f5bde6' }
: { border: '#8bd5ca', bg: 'rgba(139,213,202,0.16)', text: '#8bd5ca' };
case 'card':
return { border: '#a6da95', bg: 'rgba(166,218,149,0.16)', text: '#a6da95' };
}
}
function popupAlignment(percent: number): string {
if (percent <= 15) return 'left-0 translate-x-0';
if (percent >= 85) return 'right-0 translate-x-0';
return 'left-1/2 -translate-x-1/2';
}
function handleWrapperBlur(
event: FocusEvent<HTMLDivElement>,
onHoveredMarkerChange: (markerKey: string | null) => void,
pinnedMarkerKey: string | null,
markerKey: string,
): void {
if (pinnedMarkerKey === markerKey) return;
const nextFocused = event.relatedTarget;
if (nextFocused instanceof Node && event.currentTarget.contains(nextFocused)) {
return;
}
onHoveredMarkerChange(null);
}
function handleWrapperMouseLeave(
event: MouseEvent<HTMLDivElement>,
onHoveredMarkerChange: (markerKey: string | null) => void,
pinnedMarkerKey: string | null,
markerKey: string,
): void {
if (pinnedMarkerKey === markerKey) return;
const nextHovered = event.relatedTarget;
if (nextHovered instanceof Node && event.currentTarget.contains(nextHovered)) {
return;
}
onHoveredMarkerChange(null);
}
export function SessionEventOverlay({
markers,
tsMin,
tsMax,
plotArea,
hoveredMarkerKey,
onHoveredMarkerChange,
pinnedMarkerKey,
onPinnedMarkerChange,
noteInfos,
loadingNoteIds,
onOpenNote,
}: SessionEventOverlayProps) {
if (markers.length === 0) return null;
const rootRef = useRef<HTMLDivElement>(null);
const activeMarkerKey = resolveActiveSessionMarkerKey(hoveredMarkerKey, pinnedMarkerKey);
useEffect(() => {
if (!pinnedMarkerKey) return;
function handleDocumentPointerDown(event: PointerEvent): void {
if (rootRef.current?.contains(event.target as Node)) {
return;
}
onPinnedMarkerChange(null);
onHoveredMarkerChange(null);
}
function handleDocumentKeyDown(event: KeyboardEvent): void {
if (event.key !== 'Escape') return;
onPinnedMarkerChange(null);
onHoveredMarkerChange(null);
}
document.addEventListener('pointerdown', handleDocumentPointerDown);
document.addEventListener('keydown', handleDocumentKeyDown);
return () => {
document.removeEventListener('pointerdown', handleDocumentPointerDown);
document.removeEventListener('keydown', handleDocumentKeyDown);
};
}, [pinnedMarkerKey, onHoveredMarkerChange, onPinnedMarkerChange]);
return (
<div ref={rootRef} className="pointer-events-none absolute inset-0 z-30 overflow-visible">
{markers.map((marker) => {
const percent = toPercent(marker.anchorTsMs, tsMin, tsMax);
const left = plotArea
? `${projectSessionMarkerLeftPx({
anchorTsMs: marker.anchorTsMs,
tsMin,
tsMax,
plotLeftPx: plotArea.left,
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));
return (
<div
key={marker.key}
className="pointer-events-auto absolute top-0 -translate-x-1/2 pt-1"
style={{ left }}
onMouseEnter={() => onHoveredMarkerChange(marker.key)}
onMouseLeave={(event) =>
handleWrapperMouseLeave(event, onHoveredMarkerChange, pinnedMarkerKey, marker.key)
}
onFocusCapture={() => onHoveredMarkerChange(marker.key)}
onBlurCapture={(event) =>
handleWrapperBlur(event, onHoveredMarkerChange, pinnedMarkerKey, marker.key)
}
>
<div className="relative flex flex-col items-center">
<button
type="button"
aria-label={`Show ${marker.kind} 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"
style={{
borderColor: colors.border,
background: colors.bg,
color: colors.text,
}}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
onHoveredMarkerChange(marker.key);
onPinnedMarkerChange(togglePinnedSessionMarkerKey(pinnedMarkerKey, marker.key));
}}
>
{markerLabel(marker)}
</button>
{isActive ? (
<div
className={`pointer-events-auto absolute top-5 z-50 pt-2 ${popupAlignment(percent)}`}
onMouseDownCapture={() => {
if (!isPinned) {
onPinnedMarkerChange(marker.key);
}
}}
>
<SessionEventPopover
marker={marker}
noteInfos={noteInfos}
loading={loading}
pinned={isPinned}
onTogglePinned={() =>
onPinnedMarkerChange(
togglePinnedSessionMarkerKey(pinnedMarkerKey, marker.key),
)
}
onClose={() => {
onPinnedMarkerChange(null);
onHoveredMarkerChange(null);
}}
onOpenNote={onOpenNote}
/>
</div>
) : null}
</div>
</div>
);
})}
</div>
);
}

View File

@@ -0,0 +1,129 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { EventType } from '../types/stats';
import {
buildSessionChartEvents,
extractSessionEventNoteInfo,
projectSessionMarkerLeftPx,
resolveActiveSessionMarkerKey,
togglePinnedSessionMarkerKey,
} from './session-events';
test('buildSessionChartEvents produces typed hover markers with parsed payload metadata', () => {
const chartEvents = buildSessionChartEvents([
{ eventType: EventType.PAUSE_START, tsMs: 2_000, payload: null },
{
eventType: EventType.SEEK_FORWARD,
tsMs: 3_000,
payload: '{"fromMs":1000,"toMs":5500}',
},
{ eventType: EventType.PAUSE_END, tsMs: 5_000, payload: null },
{
eventType: EventType.CARD_MINED,
tsMs: 6_000,
payload: '{"cardsMined":2,"noteIds":[11,22]}',
},
{ eventType: EventType.YOMITAN_LOOKUP, tsMs: 7_000, payload: null },
]);
assert.deepEqual(
chartEvents.markers.map((marker) => marker.kind),
['seek', 'pause', 'card'],
);
const seekMarker = chartEvents.markers[0]!;
assert.equal(seekMarker.kind, 'seek');
assert.equal(seekMarker.direction, 'forward');
assert.equal(seekMarker.fromMs, 1_000);
assert.equal(seekMarker.toMs, 5_500);
const pauseMarker = chartEvents.markers[1]!;
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[2]!;
assert.equal(cardMarker.kind, 'card');
assert.deepEqual(cardMarker.noteIds, [11, 22]);
assert.equal(cardMarker.cardsDelta, 2);
assert.deepEqual(
chartEvents.yomitanLookupEvents.map((event) => event.tsMs),
[7_000],
);
});
test('projectSessionMarkerLeftPx respects chart plot offsets instead of full-width percentages', () => {
assert.equal(
projectSessionMarkerLeftPx({
anchorTsMs: 1_000,
tsMin: 1_000,
tsMax: 11_000,
plotLeftPx: 5,
plotWidthPx: 958,
}),
5,
);
assert.equal(
projectSessionMarkerLeftPx({
anchorTsMs: 6_000,
tsMin: 1_000,
tsMax: 11_000,
plotLeftPx: 5,
plotWidthPx: 958,
}),
484,
);
assert.equal(
projectSessionMarkerLeftPx({
anchorTsMs: 11_000,
tsMin: 1_000,
tsMax: 11_000,
plotLeftPx: 5,
plotWidthPx: 958,
}),
963,
);
});
test('extractSessionEventNoteInfo prefers expression-like fields and strips html', () => {
const info = extractSessionEventNoteInfo({
noteId: 91,
fields: {
Sentence: { value: '<div>この呪いの剣は危険だ</div>' },
Vocabulary: { value: '<span>呪いの剣</span>' },
Meaning: { value: '<div>cursed sword</div>' },
},
});
assert.deepEqual(info, {
noteId: 91,
expression: '呪いの剣',
context: 'この呪いの剣は危険だ',
meaning: 'cursed sword',
});
});
test('extractSessionEventNoteInfo ignores malformed notes without a numeric note id', () => {
assert.equal(
extractSessionEventNoteInfo({
noteId: Number.NaN,
fields: {
Vocabulary: { value: '呪い' },
},
}),
null,
);
});
test('session marker pin helpers prefer pinned markers and toggle on repeat clicks', () => {
assert.equal(resolveActiveSessionMarkerKey('card-1', 'seek-2'), 'seek-2');
assert.equal(resolveActiveSessionMarkerKey('card-1', null), 'card-1');
assert.equal(togglePinnedSessionMarkerKey(null, 'card-1'), 'card-1');
assert.equal(togglePinnedSessionMarkerKey('card-1', 'card-1'), null);
assert.equal(togglePinnedSessionMarkerKey('card-1', 'seek-2'), 'seek-2');
});

View File

@@ -0,0 +1,304 @@
import { EventType, type SessionEvent } from '../types/stats';
export const SESSION_CHART_EVENT_TYPES = [
EventType.CARD_MINED,
EventType.SEEK_FORWARD,
EventType.SEEK_BACKWARD,
EventType.PAUSE_START,
EventType.PAUSE_END,
EventType.YOMITAN_LOOKUP,
] as const;
export interface PauseRegion {
startMs: number;
endMs: number;
}
export interface SessionChartEvents {
cardEvents: SessionEvent[];
seekEvents: SessionEvent[];
yomitanLookupEvents: SessionEvent[];
pauseRegions: PauseRegion[];
markers: SessionChartMarker[];
}
export interface SessionEventNoteInfo {
noteId: number;
expression: string;
context: string | null;
meaning: string | null;
}
export interface SessionChartPlotArea {
left: number;
width: number;
}
interface SessionEventNoteField {
value: string;
}
interface SessionEventNoteRecord {
noteId: unknown;
fields?: Record<string, SessionEventNoteField> | null;
}
export type SessionChartMarker =
| {
key: string;
kind: 'pause';
anchorTsMs: number;
eventTsMs: number;
startMs: number;
endMs: number;
durationMs: number;
}
| {
key: string;
kind: 'seek';
anchorTsMs: number;
eventTsMs: number;
direction: 'forward' | 'backward';
fromMs: number | null;
toMs: number | null;
}
| {
key: string;
kind: 'card';
anchorTsMs: number;
eventTsMs: number;
noteIds: number[];
cardsDelta: number;
};
function parsePayload(payload: string | null): Record<string, unknown> | null {
if (!payload) return null;
try {
const parsed = JSON.parse(payload);
return parsed && typeof parsed === 'object' ? (parsed as Record<string, unknown>) : null;
} catch {
return null;
}
}
function readNumberField(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? value : null;
}
function readNoteIds(value: unknown): number[] {
if (!Array.isArray(value)) return [];
return value.filter(
(entry): entry is number => typeof entry === 'number' && Number.isInteger(entry),
);
}
function stripHtml(value: string): string {
return value
.replace(/\[sound:[^\]]+\]/gi, ' ')
.replace(/<br\s*\/?>/gi, ' ')
.replace(/<[^>]+>/g, ' ')
.replace(/&nbsp;/gi, ' ')
.replace(/\s+/g, ' ')
.trim();
}
function pickFieldValue(
fields: Record<string, SessionEventNoteField>,
patterns: RegExp[],
excludeValues: Set<string> = new Set(),
): string | null {
const entries = Object.entries(fields);
for (const pattern of patterns) {
for (const [fieldName, field] of entries) {
if (!pattern.test(fieldName)) continue;
const cleaned = stripHtml(field?.value ?? '');
if (cleaned && !excludeValues.has(cleaned)) return cleaned;
}
}
return null;
}
function pickExpressionField(fields: Record<string, SessionEventNoteField>): string {
const entries = Object.entries(fields);
const preferredPatterns = [
/^(expression|word|vocab|vocabulary|target|target word|front)$/i,
/(expression|word|vocab|vocabulary|target)/i,
];
const preferredValue = pickFieldValue(fields, preferredPatterns);
if (preferredValue) return preferredValue;
for (const [, field] of entries) {
const cleaned = stripHtml(field?.value ?? '');
if (cleaned) return cleaned;
}
return '';
}
export function extractSessionEventNoteInfo(
note: SessionEventNoteRecord,
): SessionEventNoteInfo | null {
if (typeof note.noteId !== 'number' || !Number.isInteger(note.noteId) || note.noteId <= 0) {
return null;
}
const fields = note.fields ?? {};
const expression = pickExpressionField(fields);
const usedValues = new Set<string>(expression ? [expression] : []);
const context =
pickFieldValue(
fields,
[/^(sentence|context|example)$/i, /(sentence|context|example)/i],
usedValues,
) ?? null;
if (context) {
usedValues.add(context);
}
const meaning =
pickFieldValue(
fields,
[
/^(meaning|definition|gloss|translation|back)$/i,
/(meaning|definition|gloss|translation|back)/i,
],
usedValues,
) ?? null;
return {
noteId: note.noteId,
expression,
context,
meaning,
};
}
export function resolveActiveSessionMarkerKey(
hoveredMarkerKey: string | null,
pinnedMarkerKey: string | null,
): string | null {
return pinnedMarkerKey ?? hoveredMarkerKey;
}
export function togglePinnedSessionMarkerKey(
currentPinnedMarkerKey: string | null,
nextMarkerKey: string,
): string | null {
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,
tsMax,
plotLeftPx,
plotWidthPx,
}: {
anchorTsMs: number;
tsMin: number;
tsMax: number;
plotLeftPx: number;
plotWidthPx: number;
}): number {
if (plotWidthPx <= 0) return plotLeftPx;
if (tsMax <= tsMin) return Math.round(plotLeftPx + plotWidthPx / 2);
const ratio = Math.max(0, Math.min(1, (anchorTsMs - tsMin) / (tsMax - tsMin)));
return Math.round(plotLeftPx + plotWidthPx * ratio);
}
export function buildSessionChartEvents(events: SessionEvent[]): SessionChartEvents {
const cardEvents: SessionEvent[] = [];
const seekEvents: 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:
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,
});
}
break;
case EventType.SEEK_FORWARD:
case EventType.SEEK_BACKWARD:
seekEvents.push(event);
{
const payload = parsePayload(event.payload);
markers.push({
key: `seek-${event.tsMs}-${event.eventType}`,
kind: 'seek',
anchorTsMs: event.tsMs,
eventTsMs: event.tsMs,
direction: event.eventType === EventType.SEEK_BACKWARD ? 'backward' : 'forward',
fromMs: readNumberField(payload?.fromMs),
toMs: readNumberField(payload?.toMs),
});
}
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,
seekEvents,
yomitanLookupEvents,
pauseRegions,
markers,
};
}