mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-20 12:11:28 -07:00
fix(stats): align session event popovers with chart plot area
This commit is contained in:
@@ -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 -->
|
||||||
@@ -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}
|
||||||
|
|||||||
219
stats/src/components/sessions/SessionEventOverlay.tsx
Normal file
219
stats/src/components/sessions/SessionEventOverlay.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
129
stats/src/lib/session-events.test.ts
Normal file
129
stats/src/lib/session-events.test.ts
Normal 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');
|
||||||
|
});
|
||||||
304
stats/src/lib/session-events.ts
Normal file
304
stats/src/lib/session-events.ts
Normal 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(/ /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,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user