feat(stats): overhaul the Trends tab (#325)

This commit is contained in:
sudacode authored and GitHub committed 2026-11-04 21:55:18 -08:00
1 parent f89f78b861
commit 269c0708cb
73 files changed
+6085 -3354

No files matched your search

+32 -4
View File
@@ -10,10 +10,13 @@ import {
createInitialStatsView,
navigateToAnime as navigateToAnimeState,
navigateToSession as navigateToSessionState,
navigateToSessionsDays,
openAnimeEpisodeDetail,
openOverviewMediaDetail,
openSessionsMediaDetail,
openTrendsMediaDetail,
switchTab,
type SessionsDayFilter,
} from './lib/stats-navigation';
const AnimeTab = lazy(() =>
@@ -72,7 +75,8 @@ export function App() {
const [mountedTabs, setMountedTabs] = useState<Set<TabId>>(() => new Set(['overview']));
const [globalWordId, setGlobalWordId] = useState<number | null>(null);
const { excluded, isExcluded, toggleExclusion, removeExclusion, clearAll } = useExcludedWords();
const { activeTab, selectedAnimeId, focusedSessionId, mediaDetail } = viewState;
const { activeTab, selectedAnimeId, focusedSessionId, sessionsDayFilter, mediaDetail } =
viewState;
const activateTab = useCallback((tabId: TabId) => {
setViewState((prev) => switchTab(prev, tabId));
@@ -104,6 +108,16 @@ export function App() {
});
}, []);
const openSessionsForDays = useCallback((filter: SessionsDayFilter) => {
setViewState((prev) => navigateToSessionsDays(prev, filter));
setMountedTabs((prev) => {
if (prev.has('sessions')) return prev;
const next = new Set(prev);
next.add('sessions');
return next;
});
}, []);
const navigateToEpisodeDetail = useCallback(
(animeId: number, videoId: number, sessionId: number | null = null) => {
setViewState((prev) => openAnimeEpisodeDetail(prev, animeId, videoId, sessionId));
@@ -122,6 +136,10 @@ export function App() {
setViewState((prev) => openSessionsMediaDetail(prev, videoId));
}, []);
const navigateToTrendsMediaDetail = useCallback((videoId: number) => {
setViewState((prev) => openTrendsMediaDetail(prev, videoId));
}, []);
const openWordDetail = useCallback((wordId: number) => {
setGlobalWordId(wordId);
}, []);
@@ -171,7 +189,9 @@ export function App() {
? 'Back to Overview'
: mediaDetail.origin.type === 'sessions'
? 'Back to Sessions'
: 'Back to Library'
: mediaDetail.origin.type === 'trends'
? 'Back to Trends'
: 'Back to Library'
}
onNavigateToAnime={navigateToAnime}
/>
@@ -222,7 +242,11 @@ export function App() {
className="animate-fade-in"
>
<Suspense fallback={<LoadingSurface label="Loading trends..." />}>
<TrendsTab />
<TrendsTab
onNavigateToAnime={navigateToAnime}
onNavigateToMediaDetail={navigateToTrendsMediaDetail}
onOpenSessionsForDays={openSessionsForDays}
/>
</Suspense>
</section>
) : null}
@@ -239,7 +263,7 @@ export function App() {
onNavigateToAnime={navigateToAnime}
onOpenWordDetail={openWordDetail}
excluded={excluded}
isExcluded={isExcluded}
onToggleExclusion={toggleExclusion}
onRemoveExclusion={removeExclusion}
onClearExclusions={clearAll}
/>
@@ -274,6 +298,10 @@ export function App() {
setViewState((prev) => ({ ...prev, focusedSessionId: null }))
}
onNavigateToMediaDetail={navigateToSessionsMediaDetail}
dayFilter={sessionsDayFilter}
onClearDayFilter={() =>
setViewState((prev) => ({ ...prev, sessionsDayFilter: null }))
}
/>
</Suspense>
</section>
@@ -0,0 +1,4 @@
/** Stands in for the mine buttons when a line can't be mined, and says why. */
export function MineUnavailableNote({ reason }: { reason: string }) {
return <span className="text-xs text-ctp-peach">{reason}</span>;
}
@@ -0,0 +1,22 @@
interface SummaryTileProps {
label: string;
value: string;
detail: string | null;
/** Smaller value for text such as dates. */
compact?: boolean;
}
/** One headline number. Group several in a grid for a summary strip. */
export function SummaryTile({ label, value, detail, compact = false }: SummaryTileProps) {
return (
<div className="min-w-0">
<div className="text-[11px] text-ctp-subtext0">{label}</div>
<div
className={`font-semibold leading-tight text-ctp-text ${compact ? 'mt-1 text-sm' : 'text-2xl'}`}
>
{value}
</div>
{detail ? <div className="text-[11px] text-ctp-overlay2">{detail}</div> : null}
</div>
);
}
+43 -40
View File
@@ -5,6 +5,7 @@ import {
renderSentenceWithMatches,
} from '../../lib/sentence-search';
import { buildStatsMineCardParams, getStatsMineCardError } from '../../lib/mining';
import { MineUnavailableNote } from '../common/MineUnavailableNote';
import type { SentenceSearchResult } from '../../types/stats';
const SEARCH_LIMIT = 50;
@@ -215,51 +216,53 @@ export function SearchTab() {
</span>
</div>
</div>
<div className="flex flex-wrap gap-2">
{availability.exactMatch && (
<button
type="button"
title={wordAudioDisabledReason || 'Create a word card from this sentence'}
className="rounded-md border border-ctp-mauve/50 px-3 py-1.5 text-xs font-medium text-ctp-mauve transition hover:bg-ctp-mauve/10 disabled:cursor-not-allowed disabled:border-ctp-surface2 disabled:text-ctp-overlay1 disabled:opacity-60"
disabled={wordStatus?.loading || !availability.canMineWordAudio}
onClick={() => void handleMine(result, index, 'word')}
>
{buttonLabel(
'word',
wordStatus,
availability.canMineWordAudio ? '' : 'Unavailable',
)}
</button>
)}
<button
type="button"
title={sentenceDisabledReason || 'Create a sentence card from this line'}
className="rounded-md border border-ctp-green/50 px-3 py-1.5 text-xs font-medium text-ctp-green transition hover:bg-ctp-green/10 disabled:cursor-not-allowed disabled:border-ctp-surface2 disabled:text-ctp-overlay1 disabled:opacity-60"
disabled={sentenceStatus?.loading || !availability.canMineSentence}
onClick={() => void handleMine(result, index, 'sentence')}
>
{buttonLabel(
'sentence',
sentenceStatus,
availability.canMineSentence ? '' : 'Unavailable',
{availability.unavailableReason ? (
<MineUnavailableNote reason={availability.unavailableReason} />
) : (
<div className="flex flex-wrap gap-2">
{availability.exactMatch && (
<button
type="button"
title={wordAudioDisabledReason || 'Create a word card from this sentence'}
className="rounded-md border border-ctp-mauve/50 px-3 py-1.5 text-xs font-medium text-ctp-mauve transition hover:bg-ctp-mauve/10 disabled:cursor-not-allowed disabled:border-ctp-surface2 disabled:text-ctp-overlay1 disabled:opacity-60"
disabled={wordStatus?.loading || !availability.canMineWordAudio}
onClick={() => void handleMine(result, index, 'word')}
>
{buttonLabel(
'word',
wordStatus,
availability.canMineWordAudio ? '' : 'No exact match',
)}
</button>
)}
</button>
{availability.exactMatch && (
<button
type="button"
title={wordAudioDisabledReason || 'Create an audio card from this sentence'}
className="rounded-md border border-ctp-blue/50 px-3 py-1.5 text-xs font-medium text-ctp-blue transition hover:bg-ctp-blue/10 disabled:cursor-not-allowed disabled:border-ctp-surface2 disabled:text-ctp-overlay1 disabled:opacity-60"
disabled={audioStatus?.loading || !availability.canMineWordAudio}
onClick={() => void handleMine(result, index, 'audio')}
title={sentenceDisabledReason || 'Create a sentence card from this line'}
className="rounded-md border border-ctp-green/50 px-3 py-1.5 text-xs font-medium text-ctp-green transition hover:bg-ctp-green/10 disabled:cursor-not-allowed disabled:border-ctp-surface2 disabled:text-ctp-overlay1 disabled:opacity-60"
disabled={sentenceStatus?.loading || !availability.canMineSentence}
onClick={() => void handleMine(result, index, 'sentence')}
>
{buttonLabel(
'audio',
audioStatus,
availability.canMineWordAudio ? '' : 'Unavailable',
)}
{buttonLabel('sentence', sentenceStatus, '')}
</button>
)}
</div>
{availability.exactMatch && (
<button
type="button"
title={
wordAudioDisabledReason || 'Create an audio card from this sentence'
}
className="rounded-md border border-ctp-blue/50 px-3 py-1.5 text-xs font-medium text-ctp-blue transition hover:bg-ctp-blue/10 disabled:cursor-not-allowed disabled:border-ctp-surface2 disabled:text-ctp-overlay1 disabled:opacity-60"
disabled={audioStatus?.loading || !availability.canMineWordAudio}
onClick={() => void handleMine(result, index, 'audio')}
>
{buttonLabel(
'audio',
audioStatus,
availability.canMineWordAudio ? '' : 'No exact match',
)}
</button>
)}
</div>
)}
</div>
<p className="mt-4 rounded-lg bg-ctp-base/70 px-4 py-3 text-base leading-7 text-ctp-text">
+26 -1
View File
@@ -6,6 +6,7 @@ import { apiClient } from '../../lib/api-client';
import { confirmBucketDelete, confirmSessionDelete } from '../../lib/delete-confirm';
import { formatDuration, formatNumber, formatSessionDayLabel } from '../../lib/formatters';
import { groupSessionsByVideo, type SessionBucket } from '../../lib/session-grouping';
import type { SessionsDayFilter } from '../../lib/stats-navigation';
import type { SessionSummary } from '../../types/stats';
function groupSessionsByDay(sessions: SessionSummary[]): Map<string, SessionSummary[]> {
@@ -79,18 +80,28 @@ export function decrementDeletingSessionCounts(
return next;
}
// Enough to show every session of any span a chart bar can open; the API caps limit at 500.
const DAY_FILTER_SESSION_LIMIT = 500;
interface SessionsTabProps {
initialSessionId?: number | null;
onClearInitialSession?: () => void;
onNavigateToMediaDetail?: (videoId: number) => void;
dayFilter?: SessionsDayFilter | null;
onClearDayFilter?: () => void;
}
export function SessionsTab({
initialSessionId,
onClearInitialSession,
onNavigateToMediaDetail,
dayFilter,
onClearDayFilter,
}: SessionsTabProps = {}) {
const { sessions, loading, error } = useSessions();
const { sessions, loading, error } = useSessions(
dayFilter ? DAY_FILTER_SESSION_LIMIT : undefined,
dayFilter,
);
const [expandedId, setExpandedId] = useState<number | null>(null);
const [expandedBuckets, setExpandedBuckets] = useState<Set<string>>(() => new Set());
const [search, setSearch] = useState('');
@@ -212,6 +223,20 @@ export function SessionsTab({
return (
<div className="space-y-4">
{dayFilter ? (
<div className="flex items-center gap-3 rounded-lg border border-ctp-surface1 bg-ctp-surface0/70 px-3 py-2 text-sm">
<span className="text-ctp-subtext0">
Sessions from <span className="text-ctp-text">{dayFilter.label}</span>
</span>
<button
type="button"
onClick={onClearDayFilter}
className="ml-auto rounded-md border border-ctp-surface2 px-2 py-1 text-xs text-ctp-text transition hover:border-ctp-blue hover:text-ctp-blue"
>
Show all sessions
</button>
</div>
) : null}
<input
type="search"
aria-label="Search sessions by title"
@@ -1,14 +1,32 @@
import type { TimeRange, GroupBy } from '../../hooks/useTrends';
import { useEffect, useState } from 'react';
import type { GroupBy, PresetTimeRange, TimeRange } from '../../hooks/useTrends';
import { epochDayToIsoDate, isoDateToEpochDay, todayLocalDay } from '../../lib/formatters';
import type { StatsDayRange } from '../../types/stats';
interface DateRangeSelectorProps {
range: TimeRange;
groupBy: GroupBy;
fillEmpty: boolean;
/** The days currently shown, used to seed the date inputs when switching to Custom. */
shownDays: { startDay: number; endDay: number } | null;
onRangeChange: (r: TimeRange) => void;
onGroupByChange: (g: GroupBy) => void;
onFillEmptyChange: (fillEmpty: boolean) => void;
}
const CUSTOM_RANGE = 'custom';
type RangeOption = PresetTimeRange | typeof CUSTOM_RANGE;
const RANGE_OPTIONS: RangeOption[] = ['7d', '30d', '90d', '365d', 'all', CUSTOM_RANGE];
const DEFAULT_CUSTOM_DAYS = 30;
const DATE_INPUT_CLASS =
'rounded-md border border-ctp-surface2 bg-ctp-surface0 px-1.5 py-1 text-xs text-ctp-text transition hover:border-ctp-blue';
function formatRangeOption(option: RangeOption): string {
if (option === 'all') return 'All';
return option === CUSTOM_RANGE ? 'Custom' : option;
}
function SegmentedControl<T extends string>({
label,
options,
@@ -29,6 +47,7 @@ function SegmentedControl<T extends string>({
{options.map((opt) => (
<button
key={opt}
type="button"
onClick={() => onChange(opt)}
aria-pressed={value === opt}
className={`px-2.5 py-1 rounded-md text-xs transition-colors ${
@@ -45,32 +64,109 @@ function SegmentedControl<T extends string>({
);
}
interface DateDrafts {
start: string;
end: string;
}
/** The range two date inputs describe, or null while either is incomplete or they are reversed. */
export function parseDateDrafts(drafts: DateDrafts): StatsDayRange | null {
const startDay = isoDateToEpochDay(drafts.start);
const endDay = isoDateToEpochDay(drafts.end);
return startDay !== null && endDay !== null && startDay <= endDay ? { startDay, endDay } : null;
}
/**
* Date inputs report every keystroke, so typing 2025 passes through years 0002 and
* 0020. Each input shows its own draft, or it would snap back and lose the digits.
* Both drafts are checked together on each edit, so a range can move forward by
* editing either end first. They reset whenever the range changes.
*/
function CustomRangeInputs({
range,
onRangeChange,
}: {
range: StatsDayRange;
onRangeChange: (range: StatsDayRange) => void;
}) {
const start = epochDayToIsoDate(range.startDay);
const end = epochDayToIsoDate(range.endDay);
const [drafts, setDrafts] = useState<DateDrafts>({ start, end });
useEffect(() => {
setDrafts({ start, end });
}, [start, end]);
const updateDrafts = (next: DateDrafts) => {
setDrafts(next);
const parsed = parseDateDrafts(next);
if (parsed) onRangeChange(parsed);
};
return (
<div className="flex items-center gap-1.5 text-xs text-ctp-overlay2">
<input
type="date"
aria-label="Range start"
className={DATE_INPUT_CLASS}
value={drafts.start}
max={drafts.end}
onChange={(event) => updateDrafts({ ...drafts, start: event.target.value })}
/>
to
<input
type="date"
aria-label="Range end"
className={DATE_INPUT_CLASS}
value={drafts.end}
min={drafts.start}
max={epochDayToIsoDate(todayLocalDay())}
onChange={(event) => updateDrafts({ ...drafts, end: event.target.value })}
/>
</div>
);
}
export function DateRangeSelector({
range,
groupBy,
fillEmpty,
shownDays,
onRangeChange,
onGroupByChange,
onFillEmptyChange,
}: DateRangeSelectorProps) {
const selectRange = (option: RangeOption) => {
if (option !== CUSTOM_RANGE) {
onRangeChange(option);
return;
}
if (typeof range !== 'string') return;
const today = todayLocalDay();
onRangeChange(shownDays ?? { startDay: today - (DEFAULT_CUSTOM_DAYS - 1), endDay: today });
};
return (
<div className="flex flex-wrap items-center gap-4 text-sm">
<SegmentedControl
label="Range"
options={['7d', '30d', '90d', '365d', 'all'] as TimeRange[]}
value={range}
onChange={onRangeChange}
formatLabel={(r) => (r === 'all' ? 'All' : r)}
options={RANGE_OPTIONS}
value={typeof range === 'string' ? range : CUSTOM_RANGE}
onChange={selectRange}
formatLabel={formatRangeOption}
/>
{typeof range === 'string' ? null : (
<CustomRangeInputs range={range} onRangeChange={onRangeChange} />
)}
<SegmentedControl
label="Group by"
options={['day', 'month'] as GroupBy[]}
options={['day', 'week', 'month'] as GroupBy[]}
value={groupBy}
onChange={onGroupByChange}
formatLabel={(g) => g.charAt(0).toUpperCase() + g.slice(1)}
/>
<SegmentedControl
label="Empty days"
label={`Empty ${groupBy}s`}
options={['show', 'hide']}
value={fillEmpty ? 'show' : 'hide'}
onChange={(v) => onFillEmptyChange(v === 'show')}
@@ -0,0 +1,77 @@
import { formatHours } from '../../lib/formatters';
interface DayHourHeatmapProps {
/** Active minutes as `[dayOfWeek][hour]`, with 0 = Sunday. */
grid: number[][];
}
const DAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
const DAY_ORDER = [1, 2, 3, 4, 5, 6, 0];
// The day starts at 06:00 so an evening that runs past midnight stays one block
// instead of being split across both ends of the chart.
const FIRST_HOUR = 6;
const HOUR_LABEL_EVERY = 3;
const MIN_CELL_OPACITY = 0.18;
export const HEATMAP_HOURS = Array.from({ length: 24 }, (_, index) => (index + FIRST_HOUR) % 24);
function formatHourLabel(hour: number): string {
return `${String(hour).padStart(2, '0')}:00`;
}
/**
* Square-root scale, so a few very long sessions don't wash out the rest of the
* grid. Returns 0 for an empty cell, which is drawn as background rather than color.
*/
export function heatmapCellOpacity(minutes: number, maxMinutes: number): number {
if (minutes <= 0 || maxMinutes <= 0) return 0;
return MIN_CELL_OPACITY + (1 - MIN_CELL_OPACITY) * Math.sqrt(minutes / maxMinutes);
}
export function DayHourHeatmap({ grid }: DayHourHeatmapProps) {
const maxMinutes = Math.max(0, ...grid.flat());
const columns = `2.25rem repeat(24, minmax(0, 1fr)) 3rem`;
return (
<div className="col-span-full rounded-lg border border-ctp-surface1 bg-ctp-surface0 p-4">
<h3 className="mb-2 text-xs font-semibold text-ctp-text">Watch Time by Day and Hour</h3>
{maxMinutes === 0 ? (
<div className="text-xs text-ctp-overlay2">No data</div>
) : (
<div className="grid items-center gap-0.5" style={{ gridTemplateColumns: columns }}>
{DAY_ORDER.map((day) => {
const hours = grid[day] ?? [];
const total = hours.reduce((sum, minutes) => sum + minutes, 0);
return (
<div key={day} className="contents">
<div className="text-[10px] text-ctp-subtext0">{DAY_NAMES[day]}</div>
{HEATMAP_HOURS.map((hour) => {
const minutes = hours[hour] ?? 0;
const opacity = heatmapCellOpacity(minutes, maxMinutes);
return (
<div
key={hour}
title={`${DAY_NAMES[day]} ${formatHourLabel(hour)}: ${formatHours(minutes)}`}
className={`h-6 rounded-sm ${opacity === 0 ? 'bg-ctp-surface1/30' : 'bg-ctp-blue'}`}
style={opacity === 0 ? undefined : { opacity }}
/>
);
})}
<div className="text-right text-[10px] text-ctp-subtext0 tabular-nums">
{formatHours(total)}
</div>
</div>
);
})}
<div />
{HEATMAP_HOURS.map((hour, index) => (
<div key={hour} className="text-[10px] whitespace-nowrap text-ctp-overlay2">
{index % HOUR_LABEL_EVERY === 0 ? formatHourLabel(hour) : ''}
</div>
))}
<div />
</div>
)}
</div>
);
}
@@ -0,0 +1,123 @@
import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
import { CHART_DEFAULTS, CHART_THEME, TOOLTIP_CONTENT_STYLE } from '../../lib/chart-theme';
import { formatNumber } from '../../lib/formatters';
import {
TREND_FREQUENCY_BANDS,
type TrendFrequencyBand,
type TrendFrequencyBandPoint,
} from '../../types/stats';
interface FrequencyBandChartProps {
data: TrendFrequencyBandPoint[];
}
const BAND_LABELS: Record<TrendFrequencyBand, string> = {
top1k: 'Top 1k',
top5k: '1k to 5k',
top10k: '5k to 10k',
top20k: '10k to 20k',
beyond20k: 'Beyond 20k',
unranked: 'Unranked',
};
// Bands are ordered, so one hue runs from faint (common) to strong (rare): the bar
// brightens as new vocabulary moves further into rare words. Unranked words have
// no place in that order and get a neutral color.
const BAND_FILLS: Record<TrendFrequencyBand, { fill: string; fillOpacity: number }> = {
top1k: { fill: '#8aadf4', fillOpacity: 0.2 },
top5k: { fill: '#8aadf4', fillOpacity: 0.36 },
top10k: { fill: '#8aadf4', fillOpacity: 0.54 },
top20k: { fill: '#8aadf4', fillOpacity: 0.74 },
beyond20k: { fill: '#8aadf4', fillOpacity: 1 },
unranked: { fill: '#6e738d', fillOpacity: 0.6 },
};
interface BandTooltipEntry {
dataKey?: unknown;
value?: unknown;
}
function BandTooltip({
active,
label,
payload,
}: {
active?: boolean;
label?: string | number;
payload?: BandTooltipEntry[];
}) {
if (!active || !payload?.length) return null;
const rows = payload.map((entry) => ({
band: TREND_FREQUENCY_BANDS.find((band) => band === entry.dataKey),
count: typeof entry.value === 'number' ? entry.value : 0,
}));
const total = rows.reduce((sum, row) => sum + row.count, 0);
return (
<div style={{ ...TOOLTIP_CONTENT_STYLE, padding: '6px 10px' }}>
<div style={{ color: CHART_THEME.tooltipLabel, marginBottom: 4, fontWeight: 600 }}>
{label}: {formatNumber(total)} new words
</div>
{rows.map(({ band, count }) =>
band ? (
<div key={band} className="flex justify-between gap-4">
<span>{BAND_LABELS[band]}</span>
<span className="tabular-nums">
{formatNumber(count)} ({total > 0 ? Math.round((count / total) * 100) : 0}%)
</span>
</div>
) : null,
)}
</div>
);
}
export function FrequencyBandChart({ data }: FrequencyBandChartProps) {
const rows = data.map((point) => ({ label: point.label, ...point.counts }));
return (
<div className="h-full rounded-lg border border-ctp-surface1 bg-ctp-surface0 p-4">
<h3 className="mb-2 text-xs font-semibold text-ctp-text">New Words by Frequency Rank</h3>
{rows.length === 0 ? (
<div className="text-xs text-ctp-overlay2">No data</div>
) : (
<>
<ResponsiveContainer width="100%" height={CHART_DEFAULTS.height}>
<BarChart data={rows} margin={CHART_DEFAULTS.margin} stackOffset="expand">
<CartesianGrid stroke={CHART_THEME.grid} {...CHART_DEFAULTS.grid} />
<XAxis
dataKey="label"
tick={{ fontSize: CHART_DEFAULTS.tickFontSize, fill: CHART_THEME.tick }}
axisLine={{ stroke: CHART_THEME.axisLine }}
tickLine={false}
/>
<YAxis
tick={{ fontSize: CHART_DEFAULTS.tickFontSize, fill: CHART_THEME.tick }}
axisLine={{ stroke: CHART_THEME.axisLine }}
tickLine={false}
width={40}
tickFormatter={(value: number) => `${Math.round(value * 100)}%`}
/>
<Tooltip content={<BandTooltip />} wrapperStyle={{ zIndex: 50 }} />
{TREND_FREQUENCY_BANDS.map((band) => (
<Bar key={band} dataKey={band} stackId="bands" {...BAND_FILLS[band]} />
))}
</BarChart>
</ResponsiveContainer>
<div className="mt-2 flex flex-wrap gap-x-3 gap-y-1">
{TREND_FREQUENCY_BANDS.map((band) => (
<span key={band} className="flex items-center gap-1 text-[10px] text-ctp-subtext0">
<span
className="inline-block h-2 w-2 shrink-0 rounded-sm"
style={{
backgroundColor: BAND_FILLS[band].fill,
opacity: BAND_FILLS[band].fillOpacity,
}}
/>
{BAND_LABELS[band]}
</span>
))}
</div>
</>
)}
</div>
);
}
@@ -1,23 +1,21 @@
import { useMemo, useState } from 'react';
import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
import type { LibrarySummaryRow } from '../../types/stats';
import { CHART_DEFAULTS, CHART_THEME, TOOLTIP_CONTENT_STYLE } from '../../lib/chart-theme';
import { epochDayToDate, formatDuration, formatNumber } from '../../lib/formatters';
interface LibrarySummarySectionProps {
rows: LibrarySummaryRow[];
hiddenTitles: ReadonlySet<string>;
selectedTitle: string | null;
onSelectTitle: (title: string) => void;
onOpenTitle: (row: LibrarySummaryRow) => void;
}
const LEADERBOARD_LIMIT = 10;
const LEADERBOARD_HEIGHT = 260;
const LEADERBOARD_BAR_COLOR = '#8aadf4';
const TABLE_MAX_HEIGHT = 480;
type SortColumn =
| 'title'
| 'watchTimeMin'
| 'videos'
| 'episodesFinished'
| 'sessions'
| 'cards'
| 'words'
@@ -37,6 +35,7 @@ const COLUMNS: ColumnDef[] = [
{ id: 'title', label: 'Title', align: 'left' },
{ id: 'watchTimeMin', label: 'Watch Time', align: 'right' },
{ id: 'videos', label: 'Videos', align: 'right' },
{ id: 'episodesFinished', label: 'Finished', align: 'right' },
{ id: 'sessions', label: 'Sessions', align: 'right' },
{ id: 'cards', label: 'Cards', align: 'right' },
{ id: 'words', label: 'Words', align: 'right' },
@@ -45,11 +44,6 @@ const COLUMNS: ColumnDef[] = [
{ id: 'firstWatched', label: 'Date Range', align: 'right' },
];
function truncateTitle(title: string, maxChars: number): string {
if (title.length <= maxChars) return title;
return `${title.slice(0, maxChars - 1)}…`;
}
function formatDateRange(firstEpochDay: number, lastEpochDay: number): string {
const fmt = (epochDay: number) =>
epochDayToDate(epochDay).toLocaleDateString(undefined, {
@@ -76,10 +70,6 @@ function compareRows(
return a.title.localeCompare(b.title) * sign;
}
if (column === 'firstWatched') {
return (a.firstWatched - b.firstWatched) * sign;
}
if (column === 'lookupsPerHundred') {
const aVal = a.lookupsPerHundred;
const bVal = b.lookupsPerHundred;
@@ -89,39 +79,28 @@ function compareRows(
return (aVal - bVal) * sign;
}
const aVal = a[column] as number;
const bVal = b[column] as number;
return (aVal - bVal) * sign;
return (a[column] - b[column]) * sign;
}
export function LibrarySummarySection({ rows, hiddenTitles }: LibrarySummarySectionProps) {
export function LibrarySummarySection({
rows,
selectedTitle,
onSelectTitle,
onOpenTitle,
}: LibrarySummarySectionProps) {
const [sortColumn, setSortColumn] = useState<SortColumn>('watchTimeMin');
const [sortDirection, setSortDirection] = useState<SortDirection>('desc');
const visibleRows = useMemo(
() => rows.filter((row) => !hiddenTitles.has(row.title)),
[rows, hiddenTitles],
);
const sortedRows = useMemo(
() => [...visibleRows].sort((a, b) => compareRows(a, b, sortColumn, sortDirection)),
[visibleRows, sortColumn, sortDirection],
() => [...rows].sort((a, b) => compareRows(a, b, sortColumn, sortDirection)),
[rows, sortColumn, sortDirection],
);
const maxWatchTime = useMemo(
() => rows.reduce((max, row) => Math.max(max, row.watchTimeMin), 0),
[rows],
);
const leaderboard = useMemo(
() =>
[...visibleRows]
.sort((a, b) => b.watchTimeMin - a.watchTimeMin)
.slice(0, LEADERBOARD_LIMIT)
.map((row) => ({
title: row.title,
displayTitle: truncateTitle(row.title, 24),
watchTimeMin: row.watchTimeMin,
})),
[visibleRows],
);
if (visibleRows.length === 0) {
if (rows.length === 0) {
return (
<div className="col-span-full rounded-lg border border-ctp-surface1 bg-ctp-surface0 p-4">
<div className="text-xs text-ctp-overlay2">No library activity in the selected window.</div>
@@ -139,86 +118,85 @@ export function LibrarySummarySection({ rows, hiddenTitles }: LibrarySummarySect
};
return (
<>
<div className="col-span-full rounded-lg border border-ctp-surface1 bg-ctp-surface0 p-4">
<h3 className="text-xs font-semibold text-ctp-text mb-2">Top Titles by Watch Time (min)</h3>
<ResponsiveContainer width="100%" height={LEADERBOARD_HEIGHT}>
<BarChart
data={leaderboard}
layout="vertical"
margin={{ top: 8, right: 16, bottom: 8, left: 8 }}
>
<CartesianGrid stroke={CHART_THEME.grid} {...CHART_DEFAULTS.grid} />
<XAxis
type="number"
tick={{ fontSize: CHART_DEFAULTS.tickFontSize, fill: CHART_THEME.tick }}
axisLine={{ stroke: CHART_THEME.axisLine }}
tickLine={false}
/>
<YAxis
type="category"
dataKey="displayTitle"
width={160}
tick={{ fontSize: CHART_DEFAULTS.tickFontSize, fill: CHART_THEME.tick }}
axisLine={{ stroke: CHART_THEME.axisLine }}
tickLine={false}
interval={0}
/>
<Tooltip
contentStyle={TOOLTIP_CONTENT_STYLE}
formatter={(value: number) => [`${value} min`, 'Watch Time']}
labelFormatter={(_label, payload) => {
const datum = payload?.[0]?.payload as { title?: string } | undefined;
return datum?.title ?? '';
}}
/>
<Bar dataKey="watchTimeMin" fill={LEADERBOARD_BAR_COLOR} />
</BarChart>
</ResponsiveContainer>
</div>
<div className="col-span-full rounded-lg border border-ctp-surface1 bg-ctp-surface0 p-4">
<h3 className="text-xs font-semibold text-ctp-text mb-2">Per-Title Summary</h3>
<div className="overflow-auto" style={{ maxHeight: TABLE_MAX_HEIGHT }}>
<table className="w-full text-xs">
<thead className="sticky top-0 bg-ctp-surface0">
<tr className="border-b border-ctp-surface1 text-ctp-subtext0">
{COLUMNS.map((column) => {
const isActive = column.id === sortColumn;
const indicator = isActive ? (sortDirection === 'asc' ? ' ▲' : ' ▼') : '';
return (
<th
key={column.id}
scope="col"
className={`px-2 py-2 font-medium select-none cursor-pointer hover:text-ctp-text ${
column.align === 'right' ? 'text-right' : 'text-left'
} ${isActive ? 'text-ctp-text' : ''}`}
onClick={() => handleHeaderClick(column.id)}
>
{column.label}
{indicator}
</th>
);
})}
</tr>
</thead>
<tbody>
{sortedRows.map((row) => (
<div className="col-span-full rounded-lg border border-ctp-surface1 bg-ctp-surface0 p-4">
<h3 className="text-xs font-semibold text-ctp-text mb-2">Per-Title Summary</h3>
<div className="overflow-auto" style={{ maxHeight: TABLE_MAX_HEIGHT }}>
<table className="w-full text-xs">
<thead className="sticky top-0 bg-ctp-surface0">
<tr className="border-b border-ctp-surface1 text-ctp-subtext0">
{COLUMNS.map((column) => {
const isActive = column.id === sortColumn;
const indicator = isActive ? (sortDirection === 'asc' ? ' ▲' : ' ▼') : '';
return (
<th
key={column.id}
scope="col"
aria-sort={
isActive ? (sortDirection === 'asc' ? 'ascending' : 'descending') : undefined
}
className={`px-2 py-2 font-medium select-none cursor-pointer hover:text-ctp-text ${
column.align === 'right' ? 'text-right' : 'text-left'
} ${isActive ? 'text-ctp-text' : ''}`}
onClick={() => handleHeaderClick(column.id)}
>
{column.label}
{indicator}
</th>
);
})}
</tr>
</thead>
<tbody>
{sortedRows.map((row) => {
const isSelected = row.title === selectedTitle;
return (
<tr
key={row.title}
className="border-b border-ctp-surface1 last:border-b-0 hover:bg-ctp-surface1/40"
className={`border-b border-ctp-surface1 last:border-b-0 hover:bg-ctp-surface1/40 ${
isSelected ? 'bg-ctp-surface1/60' : ''
}`}
>
<td
className="px-2 py-2 text-left text-ctp-text max-w-[240px] truncate"
title={row.title}
>
{row.title}
<td className="px-2 py-2 text-left max-w-[240px]">
<div className="flex min-w-0 items-center gap-1.5">
<button
type="button"
aria-pressed={isSelected}
title={`Filter the page to ${row.title}`}
className={`min-w-0 truncate text-left transition hover:text-ctp-blue ${
isSelected ? 'font-semibold text-ctp-blue' : 'text-ctp-text'
}`}
onClick={() => onSelectTitle(row.title)}
>
{row.title}
</button>
<button
type="button"
aria-label={`Open ${row.title}`}
title="Open this title"
className="shrink-0 text-ctp-overlay2 transition hover:text-ctp-blue"
onClick={() => onOpenTitle(row)}
>
↗
</button>
</div>
</td>
<td className="px-2 py-2 text-right text-ctp-text tabular-nums">
{formatWatchTime(row.watchTimeMin)}
<div>{formatWatchTime(row.watchTimeMin)}</div>
<div className="ml-auto mt-1 h-1 w-20 overflow-hidden rounded-full bg-ctp-surface1/60">
<div
className="ml-auto h-full rounded-full bg-ctp-blue"
style={{
width: `${maxWatchTime > 0 ? (row.watchTimeMin / maxWatchTime) * 100 : 0}%`,
}}
/>
</div>
</td>
<td className="px-2 py-2 text-right text-ctp-text tabular-nums">
{formatNumber(row.videos)}
</td>
<td className="px-2 py-2 text-right text-ctp-text tabular-nums">
{formatNumber(row.episodesFinished)}
</td>
<td className="px-2 py-2 text-right text-ctp-text tabular-nums">
{formatNumber(row.sessions)}
</td>
@@ -238,11 +216,11 @@ export function LibrarySummarySection({ rows, hiddenTitles }: LibrarySummarySect
{formatDateRange(row.firstWatched, row.lastWatched)}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
})}
</tbody>
</table>
</div>
</>
</div>
);
}
@@ -1,122 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
buildLineData,
sortTooltipEntries,
tooltipColumnCount,
type PerAnimeDataPoint,
} from './StackedTrendChart';
function makePoints(titleCount: number): PerAnimeDataPoint[] {
const points: PerAnimeDataPoint[] = [];
for (let i = 0; i < titleCount; i += 1) {
points.push({ epochDay: 20_000 + i, animeTitle: `Title ${i}`, value: titleCount - i });
}
return points;
}
test('sortTooltipEntries orders rows by value descending, tolerating string values', () => {
const sorted = sortTooltipEntries([
{ name: 'A', value: 5 },
{ name: 'B', value: 20 },
{ name: 'C', value: '12.5' },
{ name: 'D', value: undefined },
]);
assert.deepEqual(
sorted.map((entry) => entry.name),
['B', 'C', 'A', 'D'],
);
});
test('tooltipColumnCount wraps into extra columns as items grow, capped at 3', () => {
assert.equal(tooltipColumnCount(1), 1);
assert.equal(tooltipColumnCount(8), 1);
assert.equal(tooltipColumnCount(9), 2);
assert.equal(tooltipColumnCount(16), 2);
assert.equal(tooltipColumnCount(17), 3);
assert.equal(tooltipColumnCount(40), 3);
});
test('buildLineData keeps every title as a series instead of capping at the top 7', () => {
const { points, seriesKeys } = buildLineData(makePoints(17));
assert.equal(seriesKeys.length, 17);
assert.ok(seriesKeys.includes('Title 16'));
for (const row of points) {
for (const key of seriesKeys) {
assert.ok(key in row);
}
}
});
test('buildLineData caps series at maxSeries when set, keeping the top titles', () => {
const { points, seriesKeys } = buildLineData(makePoints(17), 7);
assert.equal(seriesKeys.length, 7);
assert.deepEqual(
seriesKeys,
Array.from({ length: 7 }, (_, i) => `Title ${i}`),
);
for (const row of points) {
assert.ok(!('Title 16' in row));
}
});
test('buildLineData orders series by total value descending', () => {
const { seriesKeys } = buildLineData([
{ epochDay: 20_000, animeTitle: 'Small', value: 1 },
{ epochDay: 20_000, animeTitle: 'Big', value: 10 },
{ epochDay: 20_001, animeTitle: 'Medium', value: 5 },
]);
assert.deepEqual(seriesKeys, ['Big', 'Medium', 'Small']);
});
test('buildLineData total mode ranks by final cumulative value, not the sum of snapshots', () => {
// "Early" plateaus at 10 across four days (snapshot sum 40); "Late" reaches a
// larger final value of 12 in a single day. Ranking by final value keeps Late
// first; the old snapshot-sum ranking would have put Early first.
const raw: PerAnimeDataPoint[] = [
{ epochDay: 20_000, animeTitle: 'Early', value: 10 },
{ epochDay: 20_001, animeTitle: 'Early', value: 10 },
{ epochDay: 20_002, animeTitle: 'Early', value: 10 },
{ epochDay: 20_003, animeTitle: 'Early', value: 10 },
{ epochDay: 20_003, animeTitle: 'Late', value: 12 },
];
const { seriesKeys } = buildLineData(raw, undefined, 'total');
assert.deepEqual(seriesKeys, ['Late', 'Early']);
});
test('buildLineData recent mode keeps the most recently active titles over the largest', () => {
// "Old Giant" has a huge cumulative total but stopped growing early; "Fresh"
// is small but its cumulative value increased on the latest day.
const raw: PerAnimeDataPoint[] = [
{ epochDay: 20_000, animeTitle: 'Old Giant', value: 500 },
{ epochDay: 20_001, animeTitle: 'Old Giant', value: 500 },
{ epochDay: 20_002, animeTitle: 'Old Giant', value: 500 },
{ epochDay: 20_000, animeTitle: 'Fresh', value: 1 },
{ epochDay: 20_001, animeTitle: 'Fresh', value: 2 },
{ epochDay: 20_002, animeTitle: 'Fresh', value: 5 },
];
const total = buildLineData(raw, 1, 'total');
assert.deepEqual(total.seriesKeys, ['Old Giant']);
const recent = buildLineData(raw, 1, 'recent');
assert.deepEqual(recent.seriesKeys, ['Fresh']);
});
test('buildLineData recent mode breaks ties by total then name', () => {
// Both titles last increased on day 20_002; the larger total wins the tie.
const raw: PerAnimeDataPoint[] = [
{ epochDay: 20_001, animeTitle: 'A', value: 2 },
{ epochDay: 20_002, animeTitle: 'A', value: 4 },
{ epochDay: 20_001, animeTitle: 'B', value: 3 },
{ epochDay: 20_002, animeTitle: 'B', value: 9 },
];
const { seriesKeys } = buildLineData(raw, 1, 'recent');
assert.deepEqual(seriesKeys, ['B']);
});
@@ -1,275 +0,0 @@
import {
AreaChart,
Area,
CartesianGrid,
XAxis,
YAxis,
Tooltip,
ResponsiveContainer,
} from 'recharts';
import { CHART_DEFAULTS, CHART_THEME, TOOLTIP_CONTENT_STYLE } from '../../lib/chart-theme';
import { epochDayToDate } from '../../lib/formatters';
export interface PerAnimeDataPoint {
epochDay: number;
animeTitle: string;
value: number;
}
interface StackedTrendChartProps {
title: string;
data: PerAnimeDataPoint[];
colorPalette?: string[];
maxSeries?: number | null;
maxSeriesMode?: SeriesRankMode;
}
const DEFAULT_LINE_COLORS = [
'#8aadf4',
'#c6a0f6',
'#a6da95',
'#f5a97f',
'#f5bde6',
'#91d7e3',
'#ee99a0',
'#f4dbd6',
];
// Wrap the tooltip into extra columns once a single column would get too tall,
// keeping it compact instead of overflowing behind the charts below it.
const TOOLTIP_ROWS_PER_COLUMN = 8;
const TOOLTIP_MAX_COLUMNS = 3;
export function tooltipColumnCount(itemCount: number): number {
const columns = Math.ceil(itemCount / TOOLTIP_ROWS_PER_COLUMN);
return Math.min(TOOLTIP_MAX_COLUMNS, Math.max(1, columns));
}
interface TooltipEntry {
name?: string | number;
value?: string | number;
color?: string;
}
interface StackedTooltipProps {
active?: boolean;
label?: string | number;
payload?: TooltipEntry[];
}
function tooltipEntryValue(entry: TooltipEntry): number {
const numeric = typeof entry.value === 'number' ? entry.value : Number(entry.value);
return Number.isFinite(numeric) ? numeric : 0;
}
// Tooltip rows are sorted by value (largest first) so the biggest contributors
// read top-down, independent of the series/stacking order.
export function sortTooltipEntries<T extends TooltipEntry>(entries: readonly T[]): T[] {
return [...entries].sort((a, b) => tooltipEntryValue(b) - tooltipEntryValue(a));
}
function StackedTooltip({ active, label, payload }: StackedTooltipProps) {
if (!active || !payload || payload.length === 0) {
return null;
}
const sorted = sortTooltipEntries(payload);
const columns = tooltipColumnCount(sorted.length);
return (
<div
style={{
...TOOLTIP_CONTENT_STYLE,
padding: '6px 10px',
maxWidth: '90vw',
}}
>
{label !== undefined && (
<div style={{ color: CHART_THEME.tooltipLabel, marginBottom: 4, fontWeight: 600 }}>
{label}
</div>
)}
<div
style={{
display: 'grid',
gridTemplateColumns: `repeat(${columns}, max-content)`,
columnGap: 16,
rowGap: 2,
}}
>
{sorted.map((entry, index) => (
<div
key={`${entry.name ?? index}`}
style={{ display: 'flex', alignItems: 'center', gap: 6, whiteSpace: 'nowrap' }}
>
<span
style={{
display: 'inline-block',
width: 8,
height: 8,
borderRadius: 9999,
background: entry.color,
flexShrink: 0,
}}
/>
<span>{entry.name}</span>
<span style={{ marginLeft: 16, color: CHART_THEME.tooltipLabel }}>{entry.value}</span>
</div>
))}
</div>
</div>
);
}
export type SeriesRankMode = 'total' | 'recent';
// Per-title ranking key. The data is cumulative per title, so `total` is the
// final (latest) cumulative value — not the sum of daily snapshots, which would
// over-weight titles that plateaued high early. `recentDay` is the latest epoch
// day the value increased, i.e. its last day of real activity — used to keep
// the most recently watched titles.
function rankTitles(raw: PerAnimeDataPoint[]): Map<string, { total: number; recentDay: number }> {
const pointsByTitle = new Map<string, PerAnimeDataPoint[]>();
for (const entry of raw) {
const list = pointsByTitle.get(entry.animeTitle) ?? [];
list.push(entry);
pointsByTitle.set(entry.animeTitle, list);
}
const stats = new Map<string, { total: number; recentDay: number }>();
for (const [title, points] of pointsByTitle) {
const sorted = [...points].sort((a, b) => a.epochDay - b.epochDay);
let previous = 0;
let recentDay = Number.NEGATIVE_INFINITY;
for (const point of sorted) {
if (point.value > previous) {
recentDay = point.epochDay;
}
previous = point.value;
}
const total = sorted.length > 0 ? sorted[sorted.length - 1]!.value : 0;
stats.set(title, { total, recentDay });
}
return stats;
}
export function buildLineData(
raw: PerAnimeDataPoint[],
maxSeries?: number | null,
mode: SeriesRankMode = 'total',
) {
const stats = rankTitles(raw);
let seriesKeys = [...stats.entries()]
.sort((a, b) => {
if (mode === 'recent') {
return (
b[1].recentDay - a[1].recentDay || b[1].total - a[1].total || a[0].localeCompare(b[0])
);
}
return b[1].total - a[1].total || a[0].localeCompare(b[0]);
})
.map(([title]) => title);
if (typeof maxSeries === 'number' && maxSeries > 0) {
seriesKeys = seriesKeys.slice(0, maxSeries);
}
const seriesSet = new Set(seriesKeys);
const byDay = new Map<number, Record<string, number>>();
for (const entry of raw) {
if (!seriesSet.has(entry.animeTitle)) continue;
const row = byDay.get(entry.epochDay) ?? {};
row[entry.animeTitle] = (row[entry.animeTitle] ?? 0) + Math.round(entry.value * 10) / 10;
byDay.set(entry.epochDay, row);
}
const points = [...byDay.entries()]
.sort(([a], [b]) => a - b)
.map(([epochDay, values]) => {
const row: Record<string, string | number> = {
label: epochDayToDate(epochDay).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
}),
};
for (const title of seriesKeys) {
row[title] = values[title] ?? 0;
}
return row;
});
return { points, seriesKeys };
}
export function StackedTrendChart({
title,
data,
colorPalette,
maxSeries,
maxSeriesMode,
}: StackedTrendChartProps) {
const { points, seriesKeys } = buildLineData(data, maxSeries, maxSeriesMode);
const colors = colorPalette ?? DEFAULT_LINE_COLORS;
if (points.length === 0) {
return (
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
<h3 className="text-xs font-semibold text-ctp-text mb-2">{title}</h3>
<div className="text-xs text-ctp-overlay2">No data</div>
</div>
);
}
return (
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
<h3 className="text-xs font-semibold text-ctp-text mb-2">{title}</h3>
<ResponsiveContainer width="100%" height={CHART_DEFAULTS.height}>
<AreaChart data={points} margin={CHART_DEFAULTS.margin}>
<CartesianGrid stroke={CHART_THEME.grid} {...CHART_DEFAULTS.grid} />
<XAxis
dataKey="label"
tick={{ fontSize: CHART_DEFAULTS.tickFontSize, fill: CHART_THEME.tick }}
axisLine={{ stroke: CHART_THEME.axisLine }}
tickLine={false}
/>
<YAxis
tick={{ fontSize: CHART_DEFAULTS.tickFontSize, fill: CHART_THEME.tick }}
axisLine={{ stroke: CHART_THEME.axisLine }}
tickLine={false}
width={32}
/>
<Tooltip
content={<StackedTooltip />}
wrapperStyle={{ zIndex: 50 }}
allowEscapeViewBox={{ x: false, y: true }}
/>
{seriesKeys.map((key, i) => (
<Area
key={key}
type="monotone"
dataKey={key}
stroke={colors[i % colors.length]}
fill={colors[i % colors.length]}
fillOpacity={0.15}
strokeWidth={1.5}
connectNulls
/>
))}
</AreaChart>
</ResponsiveContainer>
<div className="flex flex-wrap gap-x-3 gap-y-1 mt-2">
{seriesKeys.map((key, i) => (
<span
key={key}
className="flex items-center gap-1 text-[10px] text-ctp-subtext0 whitespace-nowrap"
title={key}
>
<span
className="inline-block w-2 h-2 rounded-full shrink-0"
style={{ backgroundColor: colors[i % colors.length] }}
/>
<span>{key}</span>
</span>
))}
</div>
</div>
);
}
@@ -0,0 +1,75 @@
import type { TrendSummaryTotals, TrendsDashboardData } from '../../types/stats';
import { formatCompactNumber, formatHours, formatNumber } from '../../lib/formatters';
import { SummaryTile } from '../layout/SummaryTile';
interface SummaryStripProps {
summary: TrendsDashboardData['summary'];
days: number;
}
type NumericKey = Exclude<keyof TrendSummaryTotals, 'activeDays'>;
const TILES: Array<{ key: NumericKey; label: string; format: (value: number) => string }> = [
{ key: 'watchTimeMin', label: 'Watch time', format: formatHours },
{ key: 'words', label: 'Words seen', format: formatCompactNumber },
{ key: 'newWords', label: 'New words', format: formatNumber },
{ key: 'cards', label: 'Cards mined', format: formatNumber },
{ key: 'episodes', label: 'Episodes finished', format: formatNumber },
];
/** Compares a total with the same-length period before it; null when both are zero. */
export function describePeriodChange(
current: number,
previous: number,
days: number,
): string | null {
const period = `previous ${days} day${days === 1 ? '' : 's'}`;
if (previous === 0) return current === 0 ? null : `None in ${period}`;
if (current === previous) return `Same as ${period}`;
const percent = Math.round(((current - previous) / previous) * 100);
if (percent === 0) return `Under 1% from ${period}`;
return `${percent > 0 ? '+' : '\u2212'}${Math.abs(percent)}% vs ${period}`;
}
function isEmptyPeriod(totals: TrendSummaryTotals): boolean {
return totals.activeDays === 0 && TILES.every(({ key }) => !totals[key]);
}
export function SummaryStrip({ summary, days }: SummaryStripProps) {
const { current } = summary;
// An empty period gets one note, because the same sentence on every tile reads as missing data.
const previousIsEmpty = summary.previous !== null && isEmptyPeriod(summary.previous);
const previous = previousIsEmpty ? null : summary.previous;
return (
<section
aria-label="Period summary"
className="grid grid-cols-2 gap-4 rounded-lg border border-ctp-surface1 bg-ctp-surface0 p-4 sm:grid-cols-3 lg:grid-cols-6"
>
{TILES.map(({ key, label, format }) => {
const value = current[key];
if (value === null) return null;
const previousValue = previous?.[key] ?? null;
return (
<SummaryTile
key={key}
label={label}
value={format(value)}
detail={
previousValue === null ? null : describePeriodChange(value, previousValue, days)
}
/>
);
})}
<SummaryTile
label="Active days"
value={formatNumber(current.activeDays)}
detail={previous ? `${formatNumber(previous.activeDays)} in previous ${days}` : null}
/>
{previousIsEmpty ? (
<p className="col-span-full text-[11px] text-ctp-overlay2">
Not watched in the previous {days} day{days === 1 ? '' : 's'}
</p>
) : null}
</section>
);
}
@@ -0,0 +1,81 @@
interface TitleFilterProps {
titles: string[];
query: string;
selectedTitle: string | null;
onQueryChange: (query: string) => void;
onSelectTitle: (title: string | null) => void;
}
const TITLE_OPTIONS_ID = 'trends-title-options';
/**
* Whether an input event came from picking a datalist option rather than typing.
* Firefox reports insertReplacementText. Chromium, which the stats window runs on,
* sends a plain Event with no inputType, while typing always reports one.
*/
export function isDatalistSelection(event: Event): boolean {
const inputType = 'inputType' in event ? event.inputType : undefined;
return inputType === undefined || inputType === 'insertReplacementText';
}
/**
* Typing narrows the title lists below. Picking a suggestion, or pressing Enter on
* an exact title, filters the whole page to it, as choosing it from a list row does.
* Typing alone never selects, because one title can be the start of another.
*/
export function TitleFilter({
titles,
query,
selectedTitle,
onQueryChange,
onSelectTitle,
}: TitleFilterProps) {
const selectIfTitle = (value: string): boolean => {
if (!titles.includes(value)) return false;
onSelectTitle(value);
onQueryChange('');
return true;
};
return (
<div className="flex min-w-0 flex-wrap items-center gap-2 text-xs">
<input
type="search"
aria-label="Filter by title"
placeholder="Filter by title..."
list={TITLE_OPTIONS_ID}
value={query}
onChange={(event) => {
const value = event.target.value;
if (!isDatalistSelection(event.nativeEvent) || !selectIfTitle(value)) {
onQueryChange(value);
}
}}
onKeyDown={(event) => {
// Enter during IME composition confirms the conversion, not the filter.
if (event.key === 'Enter' && !event.nativeEvent.isComposing) {
selectIfTitle(event.currentTarget.value);
}
}}
className="w-56 max-w-full rounded-lg border border-ctp-surface1 bg-ctp-surface0 px-2.5 py-1 text-xs text-ctp-text placeholder:text-ctp-overlay2 focus:border-ctp-blue focus:outline-none"
/>
<datalist id={TITLE_OPTIONS_ID}>
{titles.map((title) => (
<option key={title} value={title} />
))}
</datalist>
{selectedTitle ? (
<button
type="button"
aria-label={`Clear title filter: ${selectedTitle}`}
title="Clear title filter"
className="flex max-w-xs items-center gap-1.5 rounded-full border border-ctp-blue/60 bg-ctp-blue/12 px-2.5 py-1 text-ctp-blue transition hover:border-ctp-blue"
onClick={() => onSelectTitle(null)}
>
<span className="truncate">{selectedTitle}</span>
<span aria-hidden="true">×</span>
</button>
) : null}
</div>
);
}
@@ -0,0 +1,139 @@
import { useMemo, useState } from 'react';
import type { LibrarySummaryRow, TrendPerAnimePoint } from '../../types/stats';
import { epochDayToDate, formatHours, formatNumber } from '../../lib/formatters';
import { heatmapCellOpacity } from './DayHourHeatmap';
import { buildTitleTimeline } from './title-timeline';
interface TitleTimelineProps {
/** Titles to show, most watched first. */
rows: LibrarySummaryRow[];
points: TrendPerAnimePoint[];
window: { startDay: number; endDay: number };
selectedTitle: string | null;
onSelectTitle: (title: string) => void;
onOpenTitle: (row: LibrarySummaryRow) => void;
}
const DEFAULT_VISIBLE_ROWS = 12;
function formatCellDate(epochDay: number): string {
return epochDayToDate(epochDay).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
});
}
/** One row per title, shaded on the days it was watched, so viewing bursts are visible at a glance. */
export function TitleTimeline({
rows,
points,
window,
selectedTitle,
onSelectTitle,
onOpenTitle,
}: TitleTimelineProps) {
const [showAll, setShowAll] = useState(false);
const model = useMemo(() => buildTitleTimeline(points, window), [points, window]);
const visibleRows = showAll ? rows : rows.slice(0, DEFAULT_VISIBLE_ROWS);
return (
<div className="col-span-full rounded-lg border border-ctp-surface1 bg-ctp-surface0 p-4">
<h3 className="mb-2 text-xs font-semibold text-ctp-text">
What You Watched and When
{model.columnDays > 1 ? (
<span className="ml-2 font-normal text-ctp-overlay2">One column per week</span>
) : null}
</h3>
{rows.length === 0 ? (
<div className="text-xs text-ctp-overlay2">No titles in this range.</div>
) : (
<>
<div className="grid grid-cols-[minmax(7rem,14rem)_minmax(0,1fr)_2.5rem_2.5rem] items-center gap-x-2 gap-y-px">
<div />
<div className="relative h-4">
{model.ticks.map((tick) => (
<span
key={tick.leftPercent}
className="absolute top-0 text-[10px] whitespace-nowrap text-ctp-overlay2"
style={{ left: `${tick.leftPercent}%` }}
>
{tick.label}
</span>
))}
</div>
<div className="text-right text-[10px] text-ctp-overlay2">Time</div>
<div className="text-right text-[10px] text-ctp-overlay2">Cards</div>
{visibleRows.map((row) => {
const isSelected = row.title === selectedTitle;
return (
<div key={row.title} className="contents">
<div className="flex min-w-0 items-center gap-1">
<button
type="button"
aria-pressed={isSelected}
title={`Filter the page to ${row.title}`}
className={`min-w-0 truncate text-left text-[11px] transition hover:text-ctp-blue ${
isSelected ? 'font-semibold text-ctp-blue' : 'text-ctp-text'
}`}
onClick={() => onSelectTitle(row.title)}
>
{row.title}
</button>
<button
type="button"
aria-label={`Open ${row.title}`}
title="Open this title"
className="shrink-0 text-[11px] text-ctp-overlay2 transition hover:text-ctp-blue"
onClick={() => onOpenTitle(row)}
>
↗
</button>
</div>
<div
className={`relative h-4 rounded-sm ${isSelected ? 'bg-ctp-surface2/60' : 'bg-ctp-surface1/30'}`}
>
{model.ticks.map((tick) => (
<span
key={tick.leftPercent}
className="absolute inset-y-0 w-px bg-ctp-surface2/60"
style={{ left: `${tick.leftPercent}%` }}
/>
))}
{(model.cellsByTitle.get(row.title) ?? []).map((cell) => (
<span
key={cell.startDay}
title={`${row.title}\n${model.columnDays > 1 ? 'Week of ' : ''}${formatCellDate(cell.startDay)}: ${formatHours(cell.minutes)}`}
className="absolute inset-y-px min-w-[2px] rounded-[1px] bg-ctp-blue"
style={{
left: `${cell.leftPercent}%`,
width: `${cell.widthPercent}%`,
opacity: heatmapCellOpacity(cell.minutes, model.maxCellMinutes),
}}
/>
))}
</div>
<div className="text-right text-[10px] text-ctp-subtext0 tabular-nums">
{formatHours(row.watchTimeMin)}
</div>
<div className="text-right text-[10px] text-ctp-subtext0 tabular-nums">
{formatNumber(row.cards)}
</div>
</div>
);
})}
</div>
{rows.length > DEFAULT_VISIBLE_ROWS ? (
<button
type="button"
className="mt-2 text-[11px] text-ctp-overlay2 transition hover:text-ctp-blue"
onClick={() => setShowAll((value) => !value)}
>
{showAll ? 'Show fewer' : `Show all ${formatNumber(rows.length)} titles`}
</button>
) : null}
</>
)}
</div>
);
}
+85 -35
View File
@@ -10,39 +10,85 @@ import {
ResponsiveContainer,
} from 'recharts';
import { CHART_DEFAULTS, CHART_THEME, TOOLTIP_CONTENT_STYLE } from '../../lib/chart-theme';
import { formatCompactNumber, formatNumber } from '../../lib/formatters';
interface TrendChartProps {
title: string;
data: Array<{ label: string; value: number }>;
/** A null value is a gap: no data to plot, as opposed to a real zero. */
data: Array<{ label: string; value: number | null }>;
color: string;
type: 'bar' | 'line';
formatter?: (value: number) => string;
onBarClick?: (label: string) => void;
/** Formats axis ticks. Defaults to compact numbers such as 24K. */
tickFormatter?: (value: number) => string;
/** Formats the tooltip value. Defaults to the full number. */
valueFormatter?: (value: number) => string;
onBarClick?: (label: string, index: number) => void;
height?: number;
}
export function TrendChart({ title, data, color, type, formatter, onBarClick }: TrendChartProps) {
const formatValue = (v: number) => (formatter ? [formatter(v), title] : [String(v), title]);
const Y_AXIS_WIDTH = 40;
/** A point with gaps on both sides draws no line segment, so it gets a dot instead. */
export function isIsolatedPoint(values: ReadonlyArray<number | null>, index: number): boolean {
return (
values[index] !== null &&
values[index] !== undefined &&
(values[index - 1] ?? null) === null &&
(values[index + 1] ?? null) === null
);
}
/**
* Leads a running total with zero. Each point is the total at the end of its period, so
* without this the line would begin at the first day's whole total.
*/
export function startFromZero<T extends { label: string; value: number }>(
points: T[],
): Array<T | { label: string; value: number }> {
return points.length > 0 ? [{ label: 'Start', value: 0 }, ...points] : points;
}
export function TrendChart({
title,
data,
color,
type,
tickFormatter = formatCompactNumber,
valueFormatter = formatNumber,
onBarClick,
height = CHART_DEFAULTS.height,
}: TrendChartProps) {
const formatValue = (value: unknown) =>
typeof value === 'number' ? [valueFormatter(value), title] : ['Not enough data', title];
const values = data.map((point) => point.value);
const xAxis = (
<XAxis
dataKey="label"
tick={{ fontSize: CHART_DEFAULTS.tickFontSize, fill: CHART_THEME.tick }}
axisLine={{ stroke: CHART_THEME.axisLine }}
tickLine={false}
/>
);
const yAxis = (
<YAxis
tick={{ fontSize: CHART_DEFAULTS.tickFontSize, fill: CHART_THEME.tick }}
axisLine={{ stroke: CHART_THEME.axisLine }}
tickLine={false}
width={Y_AXIS_WIDTH}
tickFormatter={tickFormatter}
/>
);
return (
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
<div className="h-full bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
<h3 className="text-xs font-semibold text-ctp-text mb-2">{title}</h3>
<ResponsiveContainer width="100%" height={CHART_DEFAULTS.height}>
<ResponsiveContainer width="100%" height={height}>
{type === 'bar' ? (
<BarChart data={data} margin={CHART_DEFAULTS.margin}>
<CartesianGrid stroke={CHART_THEME.grid} {...CHART_DEFAULTS.grid} />
<XAxis
dataKey="label"
tick={{ fontSize: CHART_DEFAULTS.tickFontSize, fill: CHART_THEME.tick }}
axisLine={{ stroke: CHART_THEME.axisLine }}
tickLine={false}
/>
<YAxis
tick={{ fontSize: CHART_DEFAULTS.tickFontSize, fill: CHART_THEME.tick }}
axisLine={{ stroke: CHART_THEME.axisLine }}
tickLine={false}
width={32}
tickFormatter={formatter}
/>
{xAxis}
{yAxis}
<Tooltip contentStyle={TOOLTIP_CONTENT_STYLE} formatter={formatValue} />
<Bar
dataKey="value"
@@ -50,28 +96,32 @@ export function TrendChart({ title, data, color, type, formatter, onBarClick }:
radius={[2, 2, 0, 0]}
cursor={onBarClick ? 'pointer' : undefined}
onClick={
onBarClick ? (entry: { label: string }) => onBarClick(entry.label) : undefined
onBarClick
? (entry: { label: string }, index: number) => onBarClick(entry.label, index)
: undefined
}
/>
</BarChart>
) : (
<LineChart data={data} margin={CHART_DEFAULTS.margin}>
<CartesianGrid stroke={CHART_THEME.grid} {...CHART_DEFAULTS.grid} />
<XAxis
dataKey="label"
tick={{ fontSize: CHART_DEFAULTS.tickFontSize, fill: CHART_THEME.tick }}
axisLine={{ stroke: CHART_THEME.axisLine }}
tickLine={false}
/>
<YAxis
tick={{ fontSize: CHART_DEFAULTS.tickFontSize, fill: CHART_THEME.tick }}
axisLine={{ stroke: CHART_THEME.axisLine }}
tickLine={false}
width={32}
tickFormatter={formatter}
/>
{xAxis}
{yAxis}
<Tooltip contentStyle={TOOLTIP_CONTENT_STYLE} formatter={formatValue} />
<Line dataKey="value" stroke={color} strokeWidth={2} dot={false} />
<Line
dataKey="value"
stroke={color}
strokeWidth={2}
dot={(props: { cx?: number; cy?: number; index?: number }) => (
<circle
key={props.index}
cx={props.cx}
cy={props.cy}
r={props.index !== undefined && isIsolatedPoint(values, props.index) ? 2.5 : 0}
fill={color}
/>
)}
/>
</LineChart>
)}
</ResponsiveContainer>
+127 -32
View File
@@ -1,43 +1,138 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { renderToStaticMarkup } from 'react-dom/server';
import { AnimeVisibilityFilter } from './TrendsTab';
import { parseDateDrafts } from './DateRangeSelector';
import { HEATMAP_HOURS, heatmapCellOpacity } from './DayHourHeatmap';
import { describePeriodChange, SummaryStrip } from './SummaryStrip';
import { isDatalistSelection } from './TitleFilter';
import { isIsolatedPoint, startFromZero } from './TrendChart';
test('AnimeVisibilityFilter offers a per-chart title limit selector', () => {
const markup = renderToStaticMarkup(
<AnimeVisibilityFilter
animeTitles={['KonoSuba']}
hiddenAnime={new Set()}
maxTitles={7}
maxTitlesMode="total"
onShowAll={() => {}}
onHideAll={() => {}}
onToggleAnime={() => {}}
onMaxTitlesChange={() => {}}
onMaxTitlesModeChange={() => {}}
/>,
);
assert.match(markup, /per chart/);
assert.match(markup, /<option value="all">All<\/option>/);
assert.match(markup, /<option value="7" selected="">/);
test('heatmap hours start at 06:00 so a late evening is not split across the axis', () => {
assert.equal(HEATMAP_HOURS[0], 6);
assert.equal(HEATMAP_HOURS.indexOf(23) + 1, HEATMAP_HOURS.indexOf(0));
assert.equal(HEATMAP_HOURS.length, 24);
});
test('AnimeVisibilityFilter keeps the ranking mode selectable even when showing all titles', () => {
test('heatmap opacity leaves empty cells uncolored and keeps small values visible', () => {
assert.equal(heatmapCellOpacity(0, 100), 0);
assert.ok(heatmapCellOpacity(1, 100) > 0.2);
assert.equal(heatmapCellOpacity(100, 100), 1);
});
for (const c of [
{ name: 'a decrease', current: 88, previous: 100, expected: '\u221212% vs previous 30 days' },
{ name: 'an increase', current: 150, previous: 100, expected: '+50% vs previous 30 days' },
{ name: 'no change', current: 100, previous: 100, expected: 'Same as previous 30 days' },
{
name: 'a tiny change',
current: 1001,
previous: 1000,
expected: 'Under 1% from previous 30 days',
},
{ name: 'nothing before', current: 5, previous: 0, expected: 'None in previous 30 days' },
{ name: 'nothing at all', current: 0, previous: 0, expected: null },
]) {
test(`describePeriodChange reports ${c.name}`, () => {
assert.equal(describePeriodChange(c.current, c.previous, 30), c.expected);
});
}
test('SummaryStrip hides new words while filtered to a title and comparison on all-time', () => {
const markup = renderToStaticMarkup(
<AnimeVisibilityFilter
animeTitles={['KonoSuba']}
hiddenAnime={new Set()}
maxTitles={null}
maxTitlesMode="total"
onShowAll={() => {}}
onHideAll={() => {}}
onToggleAnime={() => {}}
onMaxTitlesChange={() => {}}
onMaxTitlesModeChange={() => {}}
<SummaryStrip
days={231}
summary={{
current: {
watchTimeMin: 90,
words: 24_000,
newWords: null,
cards: 3,
episodes: 1,
activeDays: 2,
},
previous: null,
}}
/>,
);
assert.match(markup, /1\.5h/);
assert.doesNotMatch(markup, /New words/);
assert.doesNotMatch(markup, /previous/);
});
assert.match(markup, /aria-label="Title ranking mode"/);
assert.doesNotMatch(markup, /aria-label="Title ranking mode"[^>]*disabled/);
const SUMMARY_CURRENT = {
watchTimeMin: 210,
words: 15_900,
newWords: null,
cards: 35,
episodes: 11,
activeDays: 3,
};
const EMPTY_PERIOD = {
watchTimeMin: 0,
words: 0,
newWords: null,
cards: 0,
episodes: 0,
activeDays: 0,
};
test('SummaryStrip shows one note instead of repeating an empty previous period', () => {
const markup = renderToStaticMarkup(
<SummaryStrip days={30} summary={{ current: SUMMARY_CURRENT, previous: EMPTY_PERIOD }} />,
);
assert.equal(markup.match(/previous 30/g)?.length, 1);
assert.match(markup, /Not watched in the previous 30 days/);
assert.doesNotMatch(markup, /None in previous/);
});
test('SummaryStrip still compares each tile when the previous period has any activity', () => {
const markup = renderToStaticMarkup(
<SummaryStrip
days={30}
summary={{
current: SUMMARY_CURRENT,
previous: { ...EMPTY_PERIOD, watchTimeMin: 105, words: 7_950, activeDays: 1 },
}}
/>,
);
assert.match(markup, /\+100% vs previous 30 days/);
assert.match(markup, /None in previous 30 days/);
assert.match(markup, /1 in previous 30/);
assert.doesNotMatch(markup, /Not watched/);
});
test('startFromZero leads a running total with a zero point', () => {
const points = [
{ label: 'Oct 24', value: 106 },
{ label: 'Oct 25', value: 155 },
];
assert.deepEqual(startFromZero(points), [{ label: 'Start', value: 0 }, ...points]);
assert.deepEqual(startFromZero([]), []);
});
test('isIsolatedPoint is true only for a value with gaps on both sides', () => {
const values = [null, 3, null, 4, 5, null];
assert.equal(isIsolatedPoint(values, 1), true);
assert.equal(isIsolatedPoint(values, 3), false);
assert.equal(isIsolatedPoint(values, 2), false);
});
test('isDatalistSelection separates picking a suggestion from typing', () => {
const inputEvent = (inputType: string) => Object.assign(new Event('input'), { inputType });
assert.equal(isDatalistSelection(new Event('input')), true);
assert.equal(isDatalistSelection(inputEvent('insertReplacementText')), true);
assert.equal(isDatalistSelection(inputEvent('insertText')), false);
assert.equal(isDatalistSelection(inputEvent('deleteContentBackward')), false);
});
test('parseDateDrafts waits for two complete dates in order', () => {
const end = '2026-03-01';
assert.deepEqual(parseDateDrafts({ start: '2026-02-01', end }), {
startDay: 20_485,
endDay: 20_513,
});
// What a date input reports partway through typing a year, or when cleared.
assert.equal(parseDateDrafts({ start: '0002-02-01', end }), null);
assert.equal(parseDateDrafts({ start: '', end }), null);
assert.equal(parseDateDrafts({ start: '2026-03-02', end }), null);
});
+319 -320
View File
@@ -1,368 +1,367 @@
import { useMemo, useState } from 'react';
import { useTrends, type TimeRange, type GroupBy } from '../../hooks/useTrends';
import { useMemo, useState, type ReactNode } from 'react';
import { useTrends, type GroupBy, type TimeRange } from '../../hooks/useTrends';
import { formatDuration, formatNumber } from '../../lib/formatters';
import type { SessionsDayFilter } from '../../lib/stats-navigation';
import type { LibrarySummaryRow, TrendBucket, TrendChartPoint } from '../../types/stats';
import { DateRangeSelector } from './DateRangeSelector';
import { TrendChart } from './TrendChart';
import { StackedTrendChart } from './StackedTrendChart';
import {
MAX_TITLES_MODES,
MAX_TITLES_OPTIONS,
buildAnimeVisibilityOptions,
filterHiddenAnimeData,
loadHiddenTitles,
loadMaxTitles,
loadMaxTitlesMode,
loadShowEmptyDays,
pruneHiddenAnime,
saveHiddenTitles,
saveMaxTitles,
saveMaxTitlesMode,
saveShowEmptyDays,
type MaxTitlesMode,
} from './anime-visibility';
import { DayHourHeatmap } from './DayHourHeatmap';
import { FrequencyBandChart } from './FrequencyBandChart';
import { LibrarySummarySection } from './LibrarySummarySection';
import { buildAnimeCumulative } from './anime-cumulative';
import { SummaryStrip } from './SummaryStrip';
import { TitleFilter } from './TitleFilter';
import { TitleTimeline } from './TitleTimeline';
import { startFromZero, TrendChart } from './TrendChart';
import {
RUNNING_TOTAL_METRICS,
loadGroupBy,
loadRange,
loadRunningTotalMetric,
loadShowEmptyDays,
saveGroupBy,
saveRange,
saveRunningTotalMetric,
saveShowEmptyDays,
type RunningTotalMetric,
} from './trends-preferences';
const MAX_TITLES_MODE_LABELS: Record<MaxTitlesMode, string> = {
recent: 'most recent',
total: 'top',
interface TrendsTabProps {
onNavigateToAnime?: (animeId: number) => void;
onNavigateToMediaDetail?: (videoId: number) => void;
onOpenSessionsForDays?: (filter: SessionsDayFilter) => void;
}
const WATCH_TIME_COLOR = '#8aadf4';
const WORDS_COLOR = '#8bd5ca';
const NEW_WORDS_COLOR = '#c6a0f6';
const EPISODES_COLOR = '#91d7e3';
const LOOKUPS_COLOR = '#f5a97f';
const READING_SPEED_COLOR = '#a6da95';
const CARDS_COLOR = 'var(--color-ctp-cards-mined)';
const RUNNING_TOTAL_LABELS: Record<RunningTotalMetric, string> = {
watchTime: 'Watch time',
words: 'Words seen',
newWords: 'New words',
cards: 'Cards mined',
episodes: 'Episodes finished',
lookups: 'Lookups',
};
function SectionHeader({ children }: { children: React.ReactNode }) {
const RUNNING_TOTAL_COLORS: Record<RunningTotalMetric, string> = {
watchTime: WATCH_TIME_COLOR,
words: WORDS_COLOR,
newWords: NEW_WORDS_COLOR,
cards: CARDS_COLOR,
episodes: EPISODES_COLOR,
lookups: LOOKUPS_COLOR,
};
function SectionHeader({ children, hint }: { children: ReactNode; hint?: string }) {
return (
<div className="col-span-full mt-6 mb-2 flex items-center gap-3">
<h3 className="text-ctp-subtext0 text-xs font-semibold uppercase tracking-widest shrink-0">
{children}
</h3>
<div className="flex-1 h-px bg-gradient-to-r from-ctp-surface1 to-transparent" />
{hint ? <span className="shrink-0 text-[11px] text-ctp-overlay2">{hint}</span> : null}
</div>
);
}
interface AnimeVisibilityFilterProps {
animeTitles: string[];
hiddenAnime: ReadonlySet<string>;
maxTitles: number | null;
maxTitlesMode: MaxTitlesMode;
onShowAll: () => void;
onHideAll: () => void;
onToggleAnime: (title: string) => void;
onMaxTitlesChange: (value: number | null) => void;
onMaxTitlesModeChange: (mode: MaxTitlesMode) => void;
/** Chart values in hours, so axis ticks land on round hours instead of odd minute counts. */
function toHours(points: TrendChartPoint[]): TrendChartPoint[] {
return points.map((point) => ({ ...point, value: point.value / 60 }));
}
export function AnimeVisibilityFilter({
animeTitles,
hiddenAnime,
maxTitles,
maxTitlesMode,
onShowAll,
onHideAll,
onToggleAnime,
onMaxTitlesChange,
onMaxTitlesModeChange,
}: AnimeVisibilityFilterProps) {
if (animeTitles.length === 0) {
return null;
}
const formatHourTick = (hours: number): string => `${+hours.toFixed(2)}h`;
const formatHourValue = (hours: number): string => formatDuration(hours * 3_600_000);
return (
<div className="col-span-full -mt-1 mb-1 rounded-lg border border-ctp-surface1 bg-ctp-surface0/70 p-3">
<div className="mb-2 flex items-center justify-between gap-3">
<div>
<h4 className="text-xs font-semibold uppercase tracking-widest text-ctp-subtext0">
Title Visibility
</h4>
<p className="mt-1 text-xs text-ctp-overlay1">
Shared across all anime trend charts. Default: show everything. Selections are saved.
</p>
</div>
<div className="flex items-center gap-2">
<label className="flex items-center gap-1 text-[11px] text-ctp-subtext0">
Show
<select
aria-label="Title ranking mode"
className="rounded-md border border-ctp-surface2 bg-ctp-surface0 px-1.5 py-1 text-[11px] font-medium text-ctp-text transition hover:border-ctp-blue"
value={maxTitlesMode}
onChange={(event) => onMaxTitlesModeChange(event.target.value as MaxTitlesMode)}
>
{MAX_TITLES_MODES.map((mode) => (
<option key={mode} value={mode}>
{MAX_TITLES_MODE_LABELS[mode]}
</option>
))}
</select>
<select
aria-label="Titles per chart"
className="rounded-md border border-ctp-surface2 bg-ctp-surface0 px-1.5 py-1 text-[11px] font-medium text-ctp-text transition hover:border-ctp-blue"
value={maxTitles === null ? 'all' : String(maxTitles)}
onChange={(event) =>
onMaxTitlesChange(event.target.value === 'all' ? null : Number(event.target.value))
}
>
<option value="all">All</option>
{MAX_TITLES_OPTIONS.map((count) => (
<option key={count} value={count}>
{count}
</option>
))}
</select>
per chart
</label>
<button
type="button"
className="rounded-md border border-ctp-surface2 px-2 py-1 text-[11px] font-medium text-ctp-text transition hover:border-ctp-blue hover:text-ctp-blue"
onClick={onShowAll}
>
All
</button>
<button
type="button"
className="rounded-md border border-ctp-surface2 px-2 py-1 text-[11px] font-medium text-ctp-text transition hover:border-ctp-peach hover:text-ctp-peach"
onClick={onHideAll}
>
None
</button>
</div>
</div>
<div className="flex flex-wrap gap-2">
{animeTitles.map((title) => {
const isVisible = !hiddenAnime.has(title);
return (
<button
key={title}
type="button"
aria-pressed={isVisible}
className={`max-w-full rounded-full border px-3 py-1 text-xs transition ${
isVisible
? 'border-ctp-blue/60 bg-ctp-blue/12 text-ctp-blue'
: 'border-ctp-surface2 bg-transparent text-ctp-subtext0'
}`}
onClick={() => onToggleAnime(title)}
title={title}
>
<span className="block truncate">{title}</span>
</button>
);
})}
</div>
</div>
);
function bucketSessionsLabel(bucket: TrendBucket, groupBy: GroupBy): string {
return groupBy === 'week' ? `the week of ${bucket.label}` : bucket.label;
}
export function TrendsTab() {
const [range, setRange] = useState<TimeRange>('30d');
const [groupBy, setGroupBy] = useState<GroupBy>('day');
export function TrendsTab({
onNavigateToAnime,
onNavigateToMediaDetail,
onOpenSessionsForDays,
}: TrendsTabProps = {}) {
const [range, setRange] = useState<TimeRange>(() => loadRange());
const [groupBy, setGroupBy] = useState<GroupBy>(() => loadGroupBy());
const [showEmptyDays, setShowEmptyDays] = useState(() => loadShowEmptyDays());
const [hiddenAnime, setHiddenAnime] = useState<Set<string>>(() => loadHiddenTitles());
const [maxTitles, setMaxTitles] = useState<number | null>(() => loadMaxTitles());
const [maxTitlesMode, setMaxTitlesMode] = useState<MaxTitlesMode>(() => loadMaxTitlesMode());
const { data, loading, error } = useTrends(range, groupBy, showEmptyDays);
const animeCumulative = useMemo(
() => (data ? buildAnimeCumulative(data.animeDaily) : null),
[data],
const [runningTotalMetric, setRunningTotalMetric] = useState(() => loadRunningTotalMetric());
const [selectedTitle, setSelectedTitle] = useState<string | null>(null);
const [titleQuery, setTitleQuery] = useState('');
const { data, loading, refreshing, error } = useTrends(
range,
groupBy,
showEmptyDays,
selectedTitle,
);
const updateHiddenAnime = (next: Set<string>) => {
setHiddenAnime(next);
saveHiddenTitles(next);
const updateRange = (next: TimeRange) => {
setRange(next);
saveRange(next);
};
const updateMaxTitles = (value: number | null) => {
setMaxTitles(value);
saveMaxTitles(value);
};
const updateMaxTitlesMode = (mode: MaxTitlesMode) => {
setMaxTitlesMode(mode);
saveMaxTitlesMode(mode);
const updateGroupBy = (next: GroupBy) => {
setGroupBy(next);
saveGroupBy(next);
};
const updateShowEmptyDays = (show: boolean) => {
setShowEmptyDays(show);
saveShowEmptyDays(show);
};
const cardsMinedColor = 'var(--color-ctp-cards-mined)';
const cardsMinedStackedColors = [
cardsMinedColor,
'#8aadf4',
'#c6a0f6',
'#f5a97f',
'#f5bde6',
'#91d7e3',
'#ee99a0',
'#f4dbd6',
];
const updateRunningTotalMetric = (metric: RunningTotalMetric) => {
setRunningTotalMetric(metric);
saveRunningTotalMetric(metric);
};
const toggleSelectedTitle = (title: string) => {
setSelectedTitle((current) => (current === title ? null : title));
};
if (loading) return <div className="text-ctp-overlay2 p-4">Loading...</div>;
if (error) return <div className="text-ctp-red p-4">Error: {error}</div>;
if (!data || !animeCumulative) return null;
const openTitle = (row: LibrarySummaryRow) => {
if (row.animeId !== null) {
onNavigateToAnime?.(row.animeId);
} else if (row.videoId !== null) {
onNavigateToMediaDetail?.(row.videoId);
}
};
const animeTitles = buildAnimeVisibilityOptions([
animeCumulative.episodes,
animeCumulative.cards,
animeCumulative.words,
animeCumulative.watchTime,
]);
const activeHiddenAnime = pruneHiddenAnime(hiddenAnime, animeTitles);
const allTitles = useMemo(() => data?.librarySummary.map((row) => row.title) ?? [], [data]);
const visibleTitleRows = useMemo(() => {
const rows = data?.librarySummary ?? [];
const query = titleQuery.trim().toLowerCase();
return query ? rows.filter((row) => row.title.toLowerCase().includes(query)) : rows;
}, [data, titleQuery]);
const filteredAnimeProgress = filterHiddenAnimeData(animeCumulative.episodes, activeHiddenAnime);
const filteredCardsProgress = filterHiddenAnimeData(animeCumulative.cards, activeHiddenAnime);
const filteredWordsProgress = filterHiddenAnimeData(animeCumulative.words, activeHiddenAnime);
const filteredWatchTimeProgress = filterHiddenAnimeData(
animeCumulative.watchTime,
activeHiddenAnime,
);
return (
<div className="space-y-4">
const controls = (
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
<DateRangeSelector
range={range}
groupBy={groupBy}
fillEmpty={showEmptyDays}
onRangeChange={setRange}
onGroupByChange={setGroupBy}
shownDays={data?.window ?? null}
onRangeChange={updateRange}
onGroupByChange={updateGroupBy}
onFillEmptyChange={updateShowEmptyDays}
/>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<SectionHeader>Activity (per {groupBy === 'month' ? 'month' : 'day'})</SectionHeader>
<TrendChart
title="Watch Time (min)"
data={data.activity.watchTime}
color="#8aadf4"
type="bar"
/>
<TrendChart
title="Cards Mined"
data={data.activity.cards}
color={cardsMinedColor}
type="bar"
/>
<TrendChart title="Words Seen" data={data.activity.words} color="#8bd5ca" type="bar" />
<TrendChart title="Sessions" data={data.activity.sessions} color="#b7bdf8" type="bar" />
<TitleFilter
titles={allTitles}
query={titleQuery}
selectedTitle={selectedTitle}
onQueryChange={setTitleQuery}
onSelectTitle={setSelectedTitle}
/>
{refreshing && data ? (
<span role="status" className="text-[11px] text-ctp-overlay2">
Updating...
</span>
) : null}
</div>
);
<SectionHeader>Cumulative Totals</SectionHeader>
<TrendChart
title="Watch Time, cumulative (min)"
data={data.progress.watchTime}
color="#8aadf4"
type="line"
/>
<TrendChart
title="Words Seen (cumulative)"
data={data.progress.words}
color="#8bd5ca"
type="line"
/>
<TrendChart
title="New Words Seen (cumulative)"
data={data.progress.newWords}
color="#c6a0f6"
type="line"
/>
<TrendChart
title="Cards Mined (cumulative)"
data={data.progress.cards}
color={cardsMinedColor}
type="line"
/>
<TrendChart
title="Episodes Watched (cumulative)"
data={data.progress.episodes}
color="#91d7e3"
type="line"
/>
<TrendChart
title="Sessions (cumulative)"
data={data.progress.sessions}
color="#b7bdf8"
type="line"
/>
<TrendChart
title="Lookups (cumulative)"
data={data.progress.lookups}
color="#f5bde6"
type="line"
/>
if (loading) {
return (
<div className="space-y-4">
{controls}
<div className="text-ctp-overlay2 p-4">Loading...</div>
</div>
);
}
if (!data) {
return (
<div className="space-y-4">
{controls}
{error ? <div className="text-ctp-red p-4">Error: {error}</div> : null}
</div>
);
}
<SectionHeader>Efficiency</SectionHeader>
<TrendChart
title="Words / Min"
data={data.ratios.readingSpeed}
color="#a6da95"
type="line"
/>
<TrendChart
title="Cards / Hour"
data={data.ratios.cardsPerHour}
color={cardsMinedColor}
type="line"
/>
<TrendChart
title="Lookups / 100 Words"
data={data.ratios.lookupsPerHundred}
color="#f5a97f"
type="line"
/>
const openBucketSessions = (_label: string, index: number) => {
const bucket = data.buckets[index];
if (!bucket) return;
onOpenSessionsForDays?.({
startDay: bucket.startDay,
endDay: bucket.endDay,
label: bucketSessionsLabel(bucket, groupBy),
});
};
const barClickProps = onOpenSessionsForDays ? { onBarClick: openBucketSessions } : {};
const windowDays = data.window.endDay - data.window.startDay + 1;
const runningTotalOptions = RUNNING_TOTAL_METRICS.filter(
(metric) => metric !== 'newWords' || data.learning !== null,
);
const activeRunningTotal = runningTotalOptions.includes(runningTotalMetric)
? runningTotalMetric
: 'watchTime';
const isWatchTimeTotal = activeRunningTotal === 'watchTime';
return (
<div className="space-y-4" aria-busy={refreshing}>
{controls}
{error ? <div className="text-sm text-ctp-red">Error: {error}</div> : null}
<SummaryStrip summary={data.summary} days={windowDays} />
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-6 gap-4">
<SectionHeader
hint={onOpenSessionsForDays ? 'Click a bar to open its sessions' : undefined}
>
Activity per {groupBy}
</SectionHeader>
<div className="xl:col-span-3">
<TrendChart
title="Watch Time (hours)"
data={toHours(data.activity.watchTime)}
color={WATCH_TIME_COLOR}
type="bar"
tickFormatter={formatHourTick}
valueFormatter={formatHourValue}
{...barClickProps}
/>
</div>
<div className="xl:col-span-3">
<TrendChart
title="Cards Mined"
data={data.activity.cards}
color={CARDS_COLOR}
type="bar"
{...barClickProps}
/>
</div>
<div className="xl:col-span-3">
<TrendChart
title="Words Seen"
data={data.activity.words}
color={WORDS_COLOR}
type="bar"
{...barClickProps}
/>
</div>
<div className="xl:col-span-3">
<TrendChart
title="Episodes Finished"
data={data.activity.episodes}
color={EPISODES_COLOR}
type="bar"
{...barClickProps}
/>
</div>
{data.learning ? (
<>
<SectionHeader hint="Dictionary forms, names excluded">Learning</SectionHeader>
<div className="xl:col-span-3">
<TrendChart
title={
groupBy === 'day'
? 'New Words per 1,000 Seen (rolling 7 days)'
: 'New Words per 1,000 Seen'
}
data={data.learning.newWordsPerThousand}
color={NEW_WORDS_COLOR}
type="line"
/>
</div>
<div className="xl:col-span-3">
<FrequencyBandChart data={data.learning.frequencyBands} />
</div>
</>
) : (
<div className="col-span-full text-xs text-ctp-overlay2">
New words are tracked across your whole library, so the Learning charts are hidden while
filtering to one title.
</div>
)}
<SectionHeader
hint={
groupBy === 'day'
? 'Rolling 7 days, gaps mean too little data'
: 'Gaps mean too little data'
}
>
Efficiency
</SectionHeader>
<div className="xl:col-span-2">
<TrendChart
title="Words / Min"
data={data.ratios.readingSpeed}
color={READING_SPEED_COLOR}
type="line"
/>
</div>
<div className="xl:col-span-2">
<TrendChart
title="Cards / Hour"
data={data.ratios.cardsPerHour}
color={CARDS_COLOR}
type="line"
/>
</div>
<div className="xl:col-span-2">
<TrendChart
title="Lookups / 100 Words"
data={data.ratios.lookupsPerHundred}
color={LOOKUPS_COLOR}
type="line"
/>
</div>
<SectionHeader>Running Total</SectionHeader>
<div className="col-span-full">
<div className="mb-2 flex flex-wrap gap-1" role="group" aria-label="Running total metric">
{runningTotalOptions.map((metric) => (
<button
key={metric}
type="button"
aria-pressed={metric === activeRunningTotal}
className={`rounded-md border px-2 py-1 text-[11px] transition ${
metric === activeRunningTotal
? 'border-ctp-surface2 bg-ctp-surface2 text-ctp-text'
: 'border-ctp-surface1 text-ctp-overlay2 hover:text-ctp-subtext0'
}`}
onClick={() => updateRunningTotalMetric(metric)}
>
{RUNNING_TOTAL_LABELS[metric]}
</button>
))}
</div>
<TrendChart
title={`${RUNNING_TOTAL_LABELS[activeRunningTotal]}, total since the start of this range`}
data={startFromZero(
isWatchTimeTotal
? toHours(data.progress.watchTime)
: data.progress[activeRunningTotal],
)}
color={RUNNING_TOTAL_COLORS[activeRunningTotal]}
type="line"
{...(isWatchTimeTotal
? { tickFormatter: formatHourTick, valueFormatter: formatHourValue }
: {})}
/>
</div>
<SectionHeader>Patterns</SectionHeader>
<TrendChart
title="Watch Time by Day of Week (min)"
data={data.patterns.watchTimeByDayOfWeek}
color="#8aadf4"
type="bar"
/>
<TrendChart
title="Watch Time by Hour (min)"
data={data.patterns.watchTimeByHour}
color="#c6a0f6"
type="bar"
/>
<DayHourHeatmap grid={data.watchTimeByDayHour} />
<SectionHeader>Library — Cumulative</SectionHeader>
<AnimeVisibilityFilter
animeTitles={animeTitles}
hiddenAnime={activeHiddenAnime}
maxTitles={maxTitles}
maxTitlesMode={maxTitlesMode}
onShowAll={() => updateHiddenAnime(new Set())}
onHideAll={() => updateHiddenAnime(new Set(animeTitles))}
onToggleAnime={(title) => {
const next = new Set(hiddenAnime);
if (next.has(title)) {
next.delete(title);
} else {
next.add(title);
}
updateHiddenAnime(next);
}}
onMaxTitlesChange={updateMaxTitles}
onMaxTitlesModeChange={updateMaxTitlesMode}
<SectionHeader
hint={`${formatNumber(data.librarySummary.length)} titles. Click a title to filter the page, ↗ to open it`}
>
Titles
</SectionHeader>
<TitleTimeline
rows={visibleTitleRows}
points={data.titleTimeline}
window={data.window}
selectedTitle={selectedTitle}
onSelectTitle={toggleSelectedTitle}
onOpenTitle={openTitle}
/>
<StackedTrendChart
title="Watch Time Progress (min)"
data={filteredWatchTimeProgress}
maxSeries={maxTitles}
maxSeriesMode={maxTitlesMode}
<LibrarySummarySection
rows={visibleTitleRows}
selectedTitle={selectedTitle}
onSelectTitle={toggleSelectedTitle}
onOpenTitle={openTitle}
/>
<StackedTrendChart
title="Episodes Progress"
data={filteredAnimeProgress}
maxSeries={maxTitles}
maxSeriesMode={maxTitlesMode}
/>
<StackedTrendChart
title="Cards Mined Progress"
data={filteredCardsProgress}
colorPalette={cardsMinedStackedColors}
maxSeries={maxTitles}
maxSeriesMode={maxTitlesMode}
/>
<StackedTrendChart
title="Words Seen Progress"
data={filteredWordsProgress}
maxSeries={maxTitles}
maxSeriesMode={maxTitlesMode}
/>
<SectionHeader>Library — Summary</SectionHeader>
<LibrarySummarySection rows={data.librarySummary} hiddenTitles={activeHiddenAnime} />
</div>
</div>
);
@@ -1,36 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { buildCumulativePerAnime } from './anime-cumulative';
test('buildCumulativePerAnime carries each title forward to the last active day', () => {
const result = buildCumulativePerAnime([
{ epochDay: 103, animeTitle: 'B', value: 4 },
{ epochDay: 100, animeTitle: 'A', value: 10 },
{ epochDay: 102, animeTitle: 'A', value: 5 },
{ epochDay: 102, animeTitle: 'A', value: 1 },
]);
assert.deepEqual(
result
.filter((point) => point.animeTitle === 'A')
.map((point) => [point.epochDay, point.value]),
[
[100, 10],
[101, 10],
[102, 16],
[103, 16],
],
);
// A title starts at its own first active day, not the earliest day overall.
assert.deepEqual(
result
.filter((point) => point.animeTitle === 'B')
.map((point) => [point.epochDay, point.value]),
[[103, 4]],
);
});
test('buildCumulativePerAnime returns nothing for no points', () => {
assert.deepEqual(buildCumulativePerAnime([]), []);
});
@@ -1,41 +0,0 @@
import type { TrendsDashboardData } from '../../types/stats';
import type { PerAnimeDataPoint } from './StackedTrendChart';
type AnimeSeries = TrendsDashboardData['animeDaily'];
/**
* Turns sparse per-title daily values into running totals: one point per
* title for every calendar day from its first active day to the last active
* day of any title, so the stacked area charts carry each total forward.
*/
export function buildCumulativePerAnime(points: PerAnimeDataPoint[]): PerAnimeDataPoint[] {
const byAnime = new Map<string, Map<number, number>>();
let maxDay = Number.NEGATIVE_INFINITY;
for (const point of points) {
const dayMap = byAnime.get(point.animeTitle) ?? new Map<number, number>();
dayMap.set(point.epochDay, (dayMap.get(point.epochDay) ?? 0) + point.value);
byAnime.set(point.animeTitle, dayMap);
maxDay = Math.max(maxDay, point.epochDay);
}
const result: PerAnimeDataPoint[] = [];
for (const [animeTitle, dayMap] of byAnime) {
const firstDay = Math.min(...dayMap.keys());
let cumulative = 0;
for (let epochDay = firstDay; epochDay <= maxDay; epochDay += 1) {
cumulative += dayMap.get(epochDay) ?? 0;
result.push({ epochDay, animeTitle, value: cumulative });
}
}
return result;
}
export function buildAnimeCumulative(daily: AnimeSeries): AnimeSeries {
return {
watchTime: buildCumulativePerAnime(daily.watchTime),
episodes: buildCumulativePerAnime(daily.episodes),
cards: buildCumulativePerAnime(daily.cards),
words: buildCumulativePerAnime(daily.words),
};
}
@@ -1,161 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { PerAnimeDataPoint } from './StackedTrendChart';
import {
buildAnimeVisibilityOptions,
filterHiddenAnimeData,
loadHiddenTitles,
loadMaxTitles,
loadMaxTitlesMode,
loadShowEmptyDays,
pruneHiddenAnime,
saveHiddenTitles,
saveMaxTitles,
saveMaxTitlesMode,
saveShowEmptyDays,
} from './anime-visibility';
function installLocalStorage(initial: Record<string, string> = {}) {
const previous = Object.getOwnPropertyDescriptor(globalThis, 'localStorage');
const values = new Map(Object.entries(initial));
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: {
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => values.set(key, value),
removeItem: (key: string) => values.delete(key),
},
});
return {
values,
restore: () => {
if (previous) {
Object.defineProperty(globalThis, 'localStorage', previous);
} else {
delete (globalThis as { localStorage?: unknown }).localStorage;
}
},
};
}
const SAMPLE_POINTS: PerAnimeDataPoint[] = [
{ epochDay: 1, animeTitle: 'KonoSuba', value: 5 },
{ epochDay: 2, animeTitle: 'KonoSuba', value: 10 },
{ epochDay: 1, animeTitle: 'Little Witch Academia', value: 6 },
{ epochDay: 1, animeTitle: 'Trapped in a Dating Sim', value: 20 },
];
test('buildAnimeVisibilityOptions sorts anime by combined contribution', () => {
const titles = buildAnimeVisibilityOptions([
SAMPLE_POINTS,
[
{ epochDay: 1, animeTitle: 'Little Witch Academia', value: 8 },
{ epochDay: 1, animeTitle: 'KonoSuba', value: 1 },
],
]);
assert.deepEqual(titles, ['Trapped in a Dating Sim', 'KonoSuba', 'Little Witch Academia']);
});
test('filterHiddenAnimeData removes globally hidden anime from chart data', () => {
const filtered = filterHiddenAnimeData(SAMPLE_POINTS, new Set(['KonoSuba']));
assert.equal(
filtered.some((point) => point.animeTitle === 'KonoSuba'),
false,
);
assert.equal(filtered.length, 2);
});
test('pruneHiddenAnime drops titles that are no longer available', () => {
const hidden = pruneHiddenAnime(new Set(['KonoSuba', 'Ghost in the Shell']), [
'KonoSuba',
'Little Witch Academia',
]);
assert.deepEqual([...hidden], ['KonoSuba']);
});
test('hidden titles round-trip through localStorage', () => {
const { restore } = installLocalStorage();
try {
saveHiddenTitles(new Set(['KonoSuba', 'One Piece']));
assert.deepEqual([...loadHiddenTitles()], ['KonoSuba', 'One Piece']);
} finally {
restore();
}
});
test('loadHiddenTitles tolerates missing or malformed stored values', () => {
const { restore } = installLocalStorage({
'subminer-stats-trends-hidden-titles': '{"not":"an array"}',
});
try {
assert.deepEqual([...loadHiddenTitles()], []);
} finally {
restore();
}
});
test('max titles preference defaults to 7 and round-trips including explicit All', () => {
const { values, restore } = installLocalStorage();
try {
// First run (nothing stored) defaults to the 7-title cap, not "All".
assert.equal(loadMaxTitles(), 7);
saveMaxTitles(5);
assert.equal(loadMaxTitles(), 5);
// "All" persists explicitly instead of collapsing back to the default.
saveMaxTitles(null);
assert.equal(loadMaxTitles(), null);
assert.equal(values.get('subminer-stats-trends-max-titles'), 'all');
} finally {
restore();
}
});
test('loadMaxTitles falls back to the default for unsupported stored values', () => {
for (const storedValue of ['8', '0', 'banana']) {
const { restore } = installLocalStorage({ 'subminer-stats-trends-max-titles': storedValue });
try {
assert.equal(loadMaxTitles(), 7);
} finally {
restore();
}
}
});
test('max titles mode defaults to recent and round-trips', () => {
const { restore } = installLocalStorage();
try {
assert.equal(loadMaxTitlesMode(), 'recent');
saveMaxTitlesMode('total');
assert.equal(loadMaxTitlesMode(), 'total');
saveMaxTitlesMode('recent');
assert.equal(loadMaxTitlesMode(), 'recent');
} finally {
restore();
}
});
test('loadMaxTitlesMode falls back to recent for unknown stored values', () => {
const { restore } = installLocalStorage({ 'subminer-stats-trends-max-titles-mode': 'sideways' });
try {
assert.equal(loadMaxTitlesMode(), 'recent');
} finally {
restore();
}
});
test('show empty days preference defaults to true and round-trips', () => {
const { restore } = installLocalStorage();
try {
assert.equal(loadShowEmptyDays(), true);
saveShowEmptyDays(false);
assert.equal(loadShowEmptyDays(), false);
saveShowEmptyDays(true);
assert.equal(loadShowEmptyDays(), true);
} finally {
restore();
}
});
@@ -1,136 +0,0 @@
import type { PerAnimeDataPoint } from './StackedTrendChart';
const HIDDEN_TITLES_KEY = 'subminer-stats-trends-hidden-titles';
const MAX_TITLES_KEY = 'subminer-stats-trends-max-titles';
const MAX_TITLES_MODE_KEY = 'subminer-stats-trends-max-titles-mode';
const SHOW_EMPTY_DAYS_KEY = 'subminer-stats-trends-show-empty-days';
export const MAX_TITLES_OPTIONS = [3, 5, 7, 10] as const;
// How the per-chart limit picks which titles survive: 'recent' keeps the most
// recently active titles, 'total' keeps the highest cumulative totals. 'recent'
// is listed first so it heads the dropdown.
export type MaxTitlesMode = 'total' | 'recent';
export const MAX_TITLES_MODES: readonly MaxTitlesMode[] = ['recent', 'total'];
// First-run defaults: show the 7 most recently active titles per chart.
const DEFAULT_MAX_TITLES = 7;
const DEFAULT_MAX_TITLES_MODE: MaxTitlesMode = 'recent';
const ALL_TITLES_STORED_VALUE = 'all';
function getStorage(): Storage | null {
try {
return globalThis.localStorage ?? null;
} catch {
return null;
}
}
export function loadHiddenTitles(): Set<string> {
try {
const raw = getStorage()?.getItem(HIDDEN_TITLES_KEY);
const parsed: unknown = raw ? JSON.parse(raw) : [];
if (!Array.isArray(parsed)) return new Set();
return new Set(parsed.filter((value): value is string => typeof value === 'string'));
} catch {
return new Set();
}
}
export function saveHiddenTitles(hidden: ReadonlySet<string>): void {
try {
getStorage()?.setItem(HIDDEN_TITLES_KEY, JSON.stringify([...hidden]));
} catch {
// Storage can be blocked in private/restricted contexts; keep the in-memory choice.
}
}
// Returns the persisted per-chart limit: a specific count, or null for "All".
// Absent/garbage storage falls back to the default count (never null), so a
// first run shows the default cap rather than every title. "All" is stored
// explicitly so it round-trips instead of collapsing back to the default.
export function loadMaxTitles(): number | null {
try {
const raw = getStorage()?.getItem(MAX_TITLES_KEY);
if (raw === ALL_TITLES_STORED_VALUE) return null;
const value = Number(raw);
return (MAX_TITLES_OPTIONS as readonly number[]).includes(value) ? value : DEFAULT_MAX_TITLES;
} catch {
return DEFAULT_MAX_TITLES;
}
}
export function saveMaxTitles(value: number | null): void {
try {
getStorage()?.setItem(MAX_TITLES_KEY, value === null ? ALL_TITLES_STORED_VALUE : String(value));
} catch {
// Storage can be blocked in private/restricted contexts; keep the in-memory choice.
}
}
export function loadMaxTitlesMode(): MaxTitlesMode {
try {
const raw = getStorage()?.getItem(MAX_TITLES_MODE_KEY);
if (raw === 'recent' || raw === 'total') return raw;
return DEFAULT_MAX_TITLES_MODE;
} catch {
return DEFAULT_MAX_TITLES_MODE;
}
}
export function saveMaxTitlesMode(mode: MaxTitlesMode): void {
try {
getStorage()?.setItem(MAX_TITLES_MODE_KEY, mode);
} catch {
// Storage can be blocked in private/restricted contexts; keep the in-memory choice.
}
}
// Whether trend charts zero-fill empty calendar buckets. Defaults to true so a
// first run shows the calendar-accurate view.
export function loadShowEmptyDays(): boolean {
try {
return getStorage()?.getItem(SHOW_EMPTY_DAYS_KEY) !== 'false';
} catch {
return true;
}
}
export function saveShowEmptyDays(show: boolean): void {
try {
getStorage()?.setItem(SHOW_EMPTY_DAYS_KEY, show ? 'true' : 'false');
} catch {
// Storage can be blocked in private/restricted contexts; keep the in-memory choice.
}
}
export function buildAnimeVisibilityOptions(datasets: PerAnimeDataPoint[][]): string[] {
const totals = new Map<string, number>();
for (const dataset of datasets) {
for (const point of dataset) {
totals.set(point.animeTitle, (totals.get(point.animeTitle) ?? 0) + point.value);
}
}
return [...totals.entries()]
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
.map(([title]) => title);
}
export function filterHiddenAnimeData(
data: PerAnimeDataPoint[],
hiddenAnime: ReadonlySet<string>,
): PerAnimeDataPoint[] {
if (hiddenAnime.size === 0) {
return data;
}
return data.filter((point) => !hiddenAnime.has(point.animeTitle));
}
export function pruneHiddenAnime(
hiddenAnime: ReadonlySet<string>,
availableAnime: readonly string[],
): Set<string> {
const availableSet = new Set(availableAnime);
return new Set([...hiddenAnime].filter((title) => availableSet.has(title)));
}
@@ -0,0 +1,63 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { buildTitleTimeline } from './title-timeline';
const point = (epochDay: number, animeTitle: string, value: number) => ({
epochDay,
animeTitle,
value,
});
test('buildTitleTimeline positions one column per day for short ranges', () => {
const model = buildTitleTimeline([point(20_002, 'A', 30), point(20_000, 'A', 10)], {
startDay: 20_000,
endDay: 20_009,
});
assert.equal(model.columnDays, 1);
assert.deepEqual(
model.cellsByTitle.get('A')?.map((cell) => [cell.startDay, cell.leftPercent, cell.minutes]),
[
[20_000, 0, 10],
[20_002, 20, 30],
],
);
assert.equal(model.maxCellMinutes, 30);
});
test('buildTitleTimeline merges days into weekly columns for long ranges', () => {
const model = buildTitleTimeline(
[point(20_000, 'A', 10), point(20_006, 'A', 5), point(20_007, 'A', 1)],
{ startDay: 20_000, endDay: 20_199 },
);
assert.equal(model.columnDays, 7);
assert.deepEqual(
model.cellsByTitle.get('A')?.map((cell) => [cell.startDay, cell.minutes]),
[
[20_000, 15],
[20_007, 1],
],
);
});
test('buildTitleTimeline ignores points outside the range', () => {
const model = buildTitleTimeline([point(19_999, 'A', 10), point(20_010, 'B', 10)], {
startDay: 20_000,
endDay: 20_009,
});
assert.equal(model.cellsByTitle.size, 0);
});
test('buildTitleTimeline labels the start of the range unless a month tick is already there', () => {
// 2024-10-05 to 2024-11-03: only November starts inside the range.
const midMonth = buildTitleTimeline([], { startDay: 20_001, endDay: 20_030 });
assert.equal(midMonth.ticks.length, 2);
assert.equal(midMonth.ticks[0]?.leftPercent, 0);
// 2024-10-01 onward: October's own tick already marks the start.
const monthStart = buildTitleTimeline([], { startDay: 19_997, endDay: 20_030 });
assert.equal(monthStart.ticks.length, 2);
assert.equal(monthStart.ticks[0]?.leftPercent, 0);
assert.notEqual(monthStart.ticks[1]?.leftPercent, 0);
});
@@ -0,0 +1,101 @@
import type { TrendPerAnimePoint } from '../../types/stats';
import { epochDayToDate } from '../../lib/formatters';
// Past this span a daily column is too thin to see, so columns become weeks.
const MAX_DAILY_COLUMN_SPAN = 120;
const WEEK_DAYS = 7;
// Past this span a tick per month no longer fits, so ticks mark quarters.
const MAX_MONTHLY_TICK_SPAN = 400;
// The start-date tick is dropped when a month tick would sit on top of it.
const MIN_START_TICK_CLEARANCE_PERCENT = 8;
export interface TimelineCell {
startDay: number;
/** Position and size along the row, as percentages of its width. */
leftPercent: number;
widthPercent: number;
minutes: number;
}
export interface TimelineTick {
label: string;
leftPercent: number;
}
export interface TitleTimelineModel {
columnDays: number;
ticks: TimelineTick[];
cellsByTitle: Map<string, TimelineCell[]>;
maxCellMinutes: number;
}
function buildTicks(startDay: number, endDay: number): TimelineTick[] {
const span = endDay - startDay + 1;
const monthStep = span > MAX_MONTHLY_TICK_SPAN ? 3 : 1;
const monthTicks: TimelineTick[] = [];
for (let day = startDay; day <= endDay; day += 1) {
const date = epochDayToDate(day);
if (date.getDate() !== 1 || date.getMonth() % monthStep !== 0) continue;
monthTicks.push({
label: date.toLocaleDateString(undefined, {
month: 'short',
...(date.getMonth() === 0 ? { year: 'numeric' } : {}),
}),
leftPercent: ((day - startDay) / span) * 100,
});
}
const firstTick = monthTicks[0];
if (firstTick && firstTick.leftPercent < MIN_START_TICK_CLEARANCE_PERCENT) return monthTicks;
const startTick = {
label: epochDayToDate(startDay).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
}),
leftPercent: 0,
};
return [startTick, ...monthTicks];
}
/** Lays per-title daily minutes out as positioned cells along the dashboard's date range. */
export function buildTitleTimeline(
points: readonly TrendPerAnimePoint[],
window: { startDay: number; endDay: number },
): TitleTimelineModel {
const span = window.endDay - window.startDay + 1;
const columnDays = span > MAX_DAILY_COLUMN_SPAN ? WEEK_DAYS : 1;
const columnCount = Math.ceil(span / columnDays);
const widthPercent = 100 / columnCount;
const minutesByTitle = new Map<string, Map<number, number>>();
for (const point of points) {
const column = Math.floor((point.epochDay - window.startDay) / columnDays);
if (column < 0 || column >= columnCount) continue;
const columns = minutesByTitle.get(point.animeTitle) ?? new Map<number, number>();
columns.set(column, (columns.get(column) ?? 0) + point.value);
minutesByTitle.set(point.animeTitle, columns);
}
let maxCellMinutes = 0;
const cellsByTitle = new Map<string, TimelineCell[]>();
for (const [title, columns] of minutesByTitle) {
const cells = [...columns.entries()]
.sort(([left], [right]) => left - right)
.map(([column, minutes]) => {
maxCellMinutes = Math.max(maxCellMinutes, minutes);
return {
startDay: window.startDay + column * columnDays,
leftPercent: column * widthPercent,
widthPercent,
minutes,
};
});
cellsByTitle.set(title, cells);
}
return {
columnDays,
ticks: buildTicks(window.startDay, window.endDay),
cellsByTitle,
maxCellMinutes,
};
}
@@ -0,0 +1,56 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
loadGroupBy,
loadRange,
loadRunningTotalMetric,
saveGroupBy,
saveRange,
saveRunningTotalMetric,
} from './trends-preferences';
function withStorage(run: () => void, initial: Record<string, string> = {}) {
const values = new Map(Object.entries(initial));
const previous = Object.getOwnPropertyDescriptor(globalThis, 'localStorage');
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: {
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => values.set(key, value),
},
});
try {
run();
} finally {
if (previous) Object.defineProperty(globalThis, 'localStorage', previous);
else Reflect.deleteProperty(globalThis, 'localStorage');
}
}
test('range and grouping survive a reload, including custom ranges', () => {
withStorage(() => {
saveRange('90d');
assert.equal(loadRange(), '90d');
saveRange({ startDay: 20_000, endDay: 20_030 });
assert.deepEqual(loadRange(), { startDay: 20_000, endDay: 20_030 });
saveGroupBy('week');
assert.equal(loadGroupBy(), 'week');
saveRunningTotalMetric('cards');
assert.equal(loadRunningTotalMetric(), 'cards');
});
});
test('unreadable stored values fall back to defaults', () => {
withStorage(
() => {
assert.equal(loadRange(), '30d');
assert.equal(loadGroupBy(), 'day');
assert.equal(loadRunningTotalMetric(), 'watchTime');
},
{
'subminer-stats-trends-range': '9:1',
'subminer-stats-trends-group-by': 'year',
'subminer-stats-trends-running-total-metric': 'nope',
},
);
});
@@ -0,0 +1,79 @@
import type { GroupBy, PresetTimeRange, TimeRange } from '../../hooks/useTrends';
import {
readBooleanPreference,
readStringPreference,
writeBooleanPreference,
writeStringPreference,
} from '../../lib/preference-storage';
const RANGE_KEY = 'subminer-stats-trends-range';
const GROUP_BY_KEY = 'subminer-stats-trends-group-by';
const SHOW_EMPTY_DAYS_KEY = 'subminer-stats-trends-show-empty-days';
const RUNNING_TOTAL_METRIC_KEY = 'subminer-stats-trends-running-total-metric';
const PRESET_RANGES: readonly string[] = [
'7d',
'30d',
'90d',
'365d',
'all',
] satisfies readonly PresetTimeRange[];
export const RUNNING_TOTAL_METRICS = [
'watchTime',
'words',
'newWords',
'cards',
'episodes',
'lookups',
] as const;
export type RunningTotalMetric = (typeof RUNNING_TOTAL_METRICS)[number];
function isPresetRange(value: string): value is PresetTimeRange {
return PRESET_RANGES.includes(value);
}
/** Custom ranges are stored as `startDay:endDay`; anything unreadable falls back to 30d. */
export function loadRange(): TimeRange {
const raw = readStringPreference(RANGE_KEY);
if (raw === null) return '30d';
if (isPresetRange(raw)) return raw;
const match = /^(\d+):(\d+)$/.exec(raw);
if (!match) return '30d';
const startDay = Number(match[1]);
const endDay = Number(match[2]);
return startDay <= endDay ? { startDay, endDay } : '30d';
}
export function saveRange(range: TimeRange): void {
writeStringPreference(
RANGE_KEY,
typeof range === 'string' ? range : `${range.startDay}:${range.endDay}`,
);
}
export function loadGroupBy(): GroupBy {
const raw = readStringPreference(GROUP_BY_KEY);
return raw === 'week' || raw === 'month' ? raw : 'day';
}
export function saveGroupBy(groupBy: GroupBy): void {
writeStringPreference(GROUP_BY_KEY, groupBy);
}
export function loadShowEmptyDays(): boolean {
return readBooleanPreference(SHOW_EMPTY_DAYS_KEY, true);
}
export function saveShowEmptyDays(show: boolean): void {
writeBooleanPreference(SHOW_EMPTY_DAYS_KEY, show);
}
export function loadRunningTotalMetric(): RunningTotalMetric {
const raw = readStringPreference(RUNNING_TOTAL_METRIC_KEY);
return RUNNING_TOTAL_METRICS.find((metric) => metric === raw) ?? 'watchTime';
}
export function saveRunningTotalMetric(metric: RunningTotalMetric): void {
writeStringPreference(RUNNING_TOTAL_METRIC_KEY, metric);
}
@@ -1,56 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { renderToStaticMarkup } from 'react-dom/server';
import { buildCrossAnimeWordRows, CrossAnimeWordsTable } from './CrossAnimeWordsTable';
import type { VocabularyEntry } from '../../types/stats';
import { withLocalStorage } from '../../test-utils/dom';
function makeEntry(over: Partial<VocabularyEntry>): VocabularyEntry {
return {
wordId: 1,
headword: '日本語',
word: '日本語',
reading: 'にほんご',
frequency: 5,
frequencyRank: 100,
animeCount: 2,
partOfSpeech: null,
firstSeen: 0,
lastSeen: 0,
...over,
} as VocabularyEntry;
}
test('cross-title rows can hide kana-only headwords', () => {
const rows = buildCrossAnimeWordRows(
[
makeEntry({ wordId: 1, headword: 'さらに', word: 'さらに', reading: 'さらに' }),
makeEntry({ wordId: 2, headword: '前に', word: '前に', reading: 'まえに' }),
makeEntry({ wordId: 3, headword: 'バカ', word: 'バカ', reading: 'バカ' }),
],
new Set(),
{ hideKnown: false, hideKanaOnly: true },
);
assert.deepEqual(
rows.map((row) => row.headword),
['前に'],
);
});
test('cross-title table uses saved Hide Kana preference on first render', () => {
const markup = withLocalStorage({ 'subminer.stats.crossAnimeWords.hideKanaOnly': 'true' }, () =>
renderToStaticMarkup(
<CrossAnimeWordsTable
words={[
makeEntry({ wordId: 1, headword: 'さらに', word: 'さらに', reading: 'さらに' }),
makeEntry({ wordId: 2, headword: '前に', word: '前に', reading: 'まえに' }),
]}
knownWords={new Set()}
/>,
),
);
assert.doesNotMatch(markup, />さらに</);
assert.match(markup, />前に</);
});
@@ -1,219 +0,0 @@
import { useMemo, useState } from 'react';
import { PosBadge } from './pos-helpers';
import { isKanaOnlyTokenText } from '../../lib/kana-token';
import { readBooleanPreference, writeBooleanPreference } from '../../lib/preference-storage';
import { fullReading } from '../../lib/reading-utils';
import type { VocabularyEntry } from '../../types/stats';
interface CrossAnimeWordsTableProps {
words: VocabularyEntry[];
knownWords: Set<string>;
onSelectWord?: (word: VocabularyEntry) => void;
}
const PAGE_SIZE = 25;
const HIDE_KANA_ONLY_STORAGE_KEY = 'subminer.stats.crossAnimeWords.hideKanaOnly';
interface CrossAnimeWordsOptions {
hideKnown: boolean;
hideKanaOnly: boolean;
}
function isWordKnown(w: VocabularyEntry, knownWords: Set<string>): boolean {
return knownWords.has(w.headword) || knownWords.has(w.word);
}
function isKanaOnlyWord(w: VocabularyEntry): boolean {
return isKanaOnlyTokenText(w.headword || w.word);
}
export function buildCrossAnimeWordRows(
words: VocabularyEntry[],
knownWords: Set<string>,
options: CrossAnimeWordsOptions,
): VocabularyEntry[] {
const hasKnownData = knownWords.size > 0;
let filtered = words.filter((w) => w.animeCount >= 2);
if (options.hideKnown && hasKnownData) {
filtered = filtered.filter((w) => !isWordKnown(w, knownWords));
}
if (options.hideKanaOnly) {
filtered = filtered.filter((w) => !isKanaOnlyWord(w));
}
const byHeadword = new Map<string, VocabularyEntry>();
for (const w of filtered) {
const existing = byHeadword.get(w.headword);
if (!existing) {
byHeadword.set(w.headword, { ...w });
} else {
existing.frequency += w.frequency;
existing.animeCount = Math.max(existing.animeCount, w.animeCount);
if (
w.frequencyRank != null &&
(existing.frequencyRank == null || w.frequencyRank < existing.frequencyRank)
) {
existing.frequencyRank = w.frequencyRank;
}
if (!existing.reading && w.reading) existing.reading = w.reading;
if (!existing.partOfSpeech && w.partOfSpeech) existing.partOfSpeech = w.partOfSpeech;
}
}
return [...byHeadword.values()].sort((a, b) => {
if (b.animeCount !== a.animeCount) return b.animeCount - a.animeCount;
return b.frequency - a.frequency;
});
}
export function CrossAnimeWordsTable({
words,
knownWords,
onSelectWord,
}: CrossAnimeWordsTableProps) {
const [page, setPage] = useState(0);
const [hideKnown, setHideKnown] = useState(true);
const [hideKanaOnly, setHideKanaOnly] = useState(() =>
readBooleanPreference(HIDE_KANA_ONLY_STORAGE_KEY, false),
);
const [collapsed, setCollapsed] = useState(false);
const hasKnownData = knownWords.size > 0;
const ranked = useMemo(() => {
return buildCrossAnimeWordRows(words, knownWords, { hideKnown, hideKanaOnly });
}, [words, knownWords, hideKnown, hideKanaOnly]);
const hasMultiAnimeWords = words.some((w) => w.animeCount >= 2);
if (!hasMultiAnimeWords) return null;
const totalPages = Math.ceil(ranked.length / PAGE_SIZE);
const paged = ranked.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
return (
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
<div className="flex items-center justify-between">
<button
type="button"
onClick={() => setCollapsed(!collapsed)}
className="flex items-center gap-2 text-sm font-semibold text-ctp-text hover:text-ctp-subtext1 transition-colors"
>
<span
className={`text-xs text-ctp-overlay2 transition-transform ${collapsed ? '' : 'rotate-90'}`}
>
{'\u25B6'}
</span>
Words Across Multiple Titles
</button>
<div className="flex flex-wrap items-center justify-end gap-2">
{hasKnownData && (
<button
type="button"
aria-pressed={hideKnown}
onClick={() => {
setHideKnown(!hideKnown);
setPage(0);
}}
className={`px-2.5 py-1 rounded-lg text-xs transition-colors border ${
hideKnown
? 'bg-ctp-surface2 text-ctp-text border-ctp-blue/50'
: 'bg-ctp-surface0 text-ctp-overlay2 border-ctp-surface1 hover:text-ctp-subtext0'
}`}
>
Hide Known
</button>
)}
<button
type="button"
aria-pressed={hideKanaOnly}
onClick={() => {
const next = !hideKanaOnly;
setHideKanaOnly(next);
writeBooleanPreference(HIDE_KANA_ONLY_STORAGE_KEY, next);
setPage(0);
}}
className={`px-2.5 py-1 rounded-lg text-xs transition-colors border ${
hideKanaOnly
? 'bg-ctp-surface2 text-ctp-text border-ctp-blue/50'
: 'bg-ctp-surface0 text-ctp-overlay2 border-ctp-surface1 hover:text-ctp-subtext0'
}`}
>
Hide Kana
</button>
<span className="text-xs text-ctp-overlay2">{ranked.length} words</span>
</div>
</div>
{collapsed ? null : ranked.length === 0 ? (
<div className="text-xs text-ctp-overlay2 mt-3">
{hideKnown && hasKnownData && !hideKanaOnly
? 'All words that span multiple titles are already known!'
: (hideKnown && hasKnownData) || hideKanaOnly
? 'No words across multiple titles match the active filters.'
: 'No words found across multiple titles.'}
</div>
) : (
<>
<div className="overflow-x-auto mt-3">
<table className="w-full text-sm">
<thead>
<tr className="text-xs text-ctp-overlay2 border-b border-ctp-surface1">
<th className="text-left py-2 pr-3 font-medium">Word</th>
<th className="text-left py-2 pr-3 font-medium">Reading</th>
<th className="text-left py-2 pr-3 font-medium w-20">POS</th>
<th className="text-right py-2 pr-3 font-medium w-16">Titles</th>
<th className="text-right py-2 font-medium w-16">Seen</th>
</tr>
</thead>
<tbody>
{paged.map((w) => (
<tr
key={w.wordId}
onClick={() => onSelectWord?.(w)}
className="border-b border-ctp-surface1 last:border-0 cursor-pointer hover:bg-ctp-surface1/50 transition-colors"
>
<td className="py-1.5 pr-3 text-ctp-text font-medium">{w.headword}</td>
<td className="py-1.5 pr-3 text-ctp-subtext0">
{fullReading(w.headword, w.reading) || w.headword}
</td>
<td className="py-1.5 pr-3">
{w.partOfSpeech && <PosBadge pos={w.partOfSpeech} />}
</td>
<td className="py-1.5 pr-3 text-right font-mono tabular-nums text-ctp-green text-xs">
{w.animeCount}
</td>
<td className="py-1.5 text-right font-mono tabular-nums text-ctp-blue text-xs">
{w.frequency}x
</td>
</tr>
))}
</tbody>
</table>
</div>
{totalPages > 1 && (
<div className="flex items-center justify-center gap-3 mt-3 text-xs">
<button
type="button"
disabled={page === 0}
onClick={() => setPage(page - 1)}
className="px-2 py-1 rounded bg-ctp-surface1 text-ctp-text disabled:opacity-30 hover:bg-ctp-surface2 transition-colors"
>
Prev
</button>
<span className="text-ctp-overlay2">
{page + 1} / {totalPages}
</span>
<button
type="button"
disabled={page >= totalPages - 1}
onClick={() => setPage(page + 1)}
className="px-2 py-1 rounded bg-ctp-surface1 text-ctp-text disabled:opacity-30 hover:bg-ctp-surface2 transition-colors"
>
Next
</button>
</div>
)}
</>
)}
</div>
);
}
@@ -1,116 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { renderToStaticMarkup } from 'react-dom/server';
import {
buildFrequencyRankRows,
FrequencyRankTable,
isKanaOnlyTokenText,
} from './FrequencyRankTable';
import type { VocabularyEntry } from '../../types/stats';
import { withLocalStorage } from '../../test-utils/dom';
function makeEntry(over: Partial<VocabularyEntry>): VocabularyEntry {
return {
wordId: 1,
headword: '日本語',
word: '日本語',
reading: 'にほんご',
frequency: 5,
frequencyRank: 100,
animeCount: 1,
partOfSpeech: null,
firstSeen: 0,
lastSeen: 0,
...over,
} as VocabularyEntry;
}
test('renders headword and reading inline in a single column (no separate Reading header)', () => {
const entry = makeEntry({});
const markup = renderToStaticMarkup(
<FrequencyRankTable words={[entry]} knownWords={new Set()} />,
);
assert.ok(!markup.includes('>Reading<'), 'should not have a Reading column header');
assert.ok(markup.includes('日本語'), 'should include the headword');
assert.ok(markup.includes('にほんご'), 'should include the reading inline');
});
test('omits reading when reading equals headword', () => {
const entry = makeEntry({ headword: 'カレー', word: 'カレー', reading: 'カレー' });
const markup = renderToStaticMarkup(
<FrequencyRankTable words={[entry]} knownWords={new Set()} />,
);
assert.ok(markup.includes('カレー'), 'should include the headword');
assert.ok(
!markup.includes('【'),
'should not render any bracketed reading when equal to headword',
);
});
test('identifies kana-only token text without hiding mixed kanji words', () => {
assert.equal(isKanaOnlyTokenText('さらに'), true);
assert.equal(isKanaOnlyTokenText('バカ'), true);
assert.equal(isKanaOnlyTokenText('カレー'), true);
assert.equal(isKanaOnlyTokenText('前に'), false);
assert.equal(isKanaOnlyTokenText('間違いない'), false);
});
test('frequency rows can hide kana-only headwords', () => {
const rows = buildFrequencyRankRows(
[
makeEntry({ wordId: 1, headword: 'さらに', word: 'さらに', frequencyRank: 10 }),
makeEntry({
wordId: 2,
headword: '前に',
word: '前に',
reading: 'まえに',
frequencyRank: 20,
}),
makeEntry({ wordId: 3, headword: 'バカ', word: 'バカ', reading: 'バカ', frequencyRank: 30 }),
],
new Set(),
{ hideKnown: false, hideKanaOnly: true },
);
assert.deepEqual(
rows.map((row) => row.headword),
['前に'],
);
});
test('uses saved Hide Kana preference on first render', () => {
const markup = withLocalStorage({ 'subminer.stats.frequencyRank.hideKanaOnly': 'true' }, () =>
renderToStaticMarkup(
<FrequencyRankTable
words={[
makeEntry({ wordId: 1, headword: 'さらに', word: 'さらに', frequencyRank: 10 }),
makeEntry({
wordId: 2,
headword: '前に',
word: '前に',
reading: 'まえに',
frequencyRank: 20,
}),
]}
knownWords={new Set()}
/>,
),
);
assert.doesNotMatch(markup, />さらに</);
assert.match(markup, />前に</);
});
test('uses saved Hide Known preference on first render', () => {
const markup = withLocalStorage({ 'subminer.stats.frequencyRank.hideKnown': 'false' }, () =>
renderToStaticMarkup(
<FrequencyRankTable
words={[makeEntry({ headword: '日本語', word: '日本語', frequencyRank: 10 })]}
knownWords={new Set(['日本語'])}
/>,
),
);
assert.match(markup, />Most Common Words Seen</);
assert.match(markup, />日本語</);
});
@@ -1,238 +0,0 @@
import { useMemo, useState } from 'react';
import { PosBadge } from './pos-helpers';
import { fullReading } from '../../lib/reading-utils';
import { isKanaOnlyTokenText } from '../../lib/kana-token';
import { readBooleanPreference, writeBooleanPreference } from '../../lib/preference-storage';
import type { VocabularyEntry } from '../../types/stats';
interface FrequencyRankTableProps {
words: VocabularyEntry[];
knownWords: Set<string>;
onSelectWord?: (word: VocabularyEntry) => void;
}
const PAGE_SIZE = 25;
const HIDE_KNOWN_STORAGE_KEY = 'subminer.stats.frequencyRank.hideKnown';
const HIDE_KANA_ONLY_STORAGE_KEY = 'subminer.stats.frequencyRank.hideKanaOnly';
interface FrequencyRankOptions {
hideKnown: boolean;
hideKanaOnly: boolean;
}
export { isKanaOnlyTokenText };
function isWordKnown(w: VocabularyEntry, knownWords: Set<string>): boolean {
return knownWords.has(w.headword) || knownWords.has(w.word);
}
function isKanaOnlyWord(w: VocabularyEntry): boolean {
return isKanaOnlyTokenText(w.headword || w.word);
}
export function buildFrequencyRankRows(
words: VocabularyEntry[],
knownWords: Set<string>,
options: FrequencyRankOptions,
): VocabularyEntry[] {
const hasKnownData = knownWords.size > 0;
let filtered = words.filter((w) => w.frequencyRank != null && w.frequencyRank > 0);
if (options.hideKnown && hasKnownData) {
filtered = filtered.filter((w) => !isWordKnown(w, knownWords));
}
if (options.hideKanaOnly) {
filtered = filtered.filter((w) => !isKanaOnlyWord(w));
}
const byHeadword = new Map<string, VocabularyEntry>();
for (const w of filtered) {
const existing = byHeadword.get(w.headword);
if (!existing) {
byHeadword.set(w.headword, { ...w });
} else {
existing.frequency += w.frequency;
existing.animeCount = Math.max(existing.animeCount, w.animeCount);
if (w.frequencyRank! < existing.frequencyRank!) {
existing.frequencyRank = w.frequencyRank;
}
if (!existing.reading && w.reading) {
existing.reading = w.reading;
}
if (!existing.partOfSpeech && w.partOfSpeech) {
existing.partOfSpeech = w.partOfSpeech;
}
}
}
return [...byHeadword.values()].sort((a, b) => a.frequencyRank! - b.frequencyRank!);
}
export function FrequencyRankTable({ words, knownWords, onSelectWord }: FrequencyRankTableProps) {
const [page, setPage] = useState(0);
const [hideKnown, setHideKnown] = useState(() =>
readBooleanPreference(HIDE_KNOWN_STORAGE_KEY, true),
);
const [hideKanaOnly, setHideKanaOnly] = useState(() =>
readBooleanPreference(HIDE_KANA_ONLY_STORAGE_KEY, false),
);
const [collapsed, setCollapsed] = useState(false);
const hasKnownData = knownWords.size > 0;
const ranked = useMemo(() => {
return buildFrequencyRankRows(words, knownWords, { hideKnown, hideKanaOnly });
}, [words, knownWords, hideKnown, hideKanaOnly]);
if (words.every((w) => w.frequencyRank == null)) {
return (
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
<h3 className="text-sm font-semibold text-ctp-text mb-2">Most Common Words Seen</h3>
<div className="text-xs text-ctp-overlay2">
No frequency rank data available. Run the frequency backfill script or install a frequency
dictionary.
</div>
</div>
);
}
const totalPages = Math.ceil(ranked.length / PAGE_SIZE);
const paged = ranked.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
return (
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
<div className="flex items-center justify-between">
<button
type="button"
onClick={() => setCollapsed(!collapsed)}
className="flex items-center gap-2 text-sm font-semibold text-ctp-text hover:text-ctp-subtext1 transition-colors"
>
<span
className={`text-xs text-ctp-overlay2 transition-transform ${collapsed ? '' : 'rotate-90'}`}
>
{'\u25B6'}
</span>
{hideKnown && hasKnownData ? 'Common Words Not Yet Mined' : 'Most Common Words Seen'}
</button>
<div className="flex flex-wrap items-center justify-end gap-2">
{hasKnownData && (
<button
type="button"
aria-pressed={hideKnown}
onClick={() => {
const next = !hideKnown;
setHideKnown(next);
writeBooleanPreference(HIDE_KNOWN_STORAGE_KEY, next);
setPage(0);
}}
className={`px-2.5 py-1 rounded-lg text-xs transition-colors border ${
hideKnown
? 'bg-ctp-surface2 text-ctp-text border-ctp-blue/50'
: 'bg-ctp-surface0 text-ctp-overlay2 border-ctp-surface1 hover:text-ctp-subtext0'
}`}
>
Hide Known
</button>
)}
<button
type="button"
aria-pressed={hideKanaOnly}
onClick={() => {
const next = !hideKanaOnly;
setHideKanaOnly(next);
writeBooleanPreference(HIDE_KANA_ONLY_STORAGE_KEY, next);
setPage(0);
}}
className={`px-2.5 py-1 rounded-lg text-xs transition-colors border ${
hideKanaOnly
? 'bg-ctp-surface2 text-ctp-text border-ctp-blue/50'
: 'bg-ctp-surface0 text-ctp-overlay2 border-ctp-surface1 hover:text-ctp-subtext0'
}`}
>
Hide Kana
</button>
<span className="text-xs text-ctp-overlay2">{ranked.length} words</span>
</div>
</div>
{collapsed ? null : ranked.length === 0 ? (
<div className="text-xs text-ctp-overlay2 mt-3">
{hideKnown && hasKnownData && !hideKanaOnly
? 'All ranked words are already in Anki!'
: (hideKnown && hasKnownData) || hideKanaOnly
? 'No ranked words match the active filters.'
: 'No words with frequency data.'}
</div>
) : (
<>
<div className="overflow-x-auto mt-3">
<table className="w-full text-sm">
<thead>
<tr className="text-xs text-ctp-overlay2 border-b border-ctp-surface1">
<th className="text-left py-2 pr-3 font-medium w-16">Rank</th>
<th className="text-left py-2 pr-3 font-medium">Word</th>
<th className="text-left py-2 pr-3 font-medium w-20">POS</th>
<th className="text-right py-2 font-medium w-20">Seen</th>
</tr>
</thead>
<tbody>
{paged.map((w) => (
<tr
key={w.wordId}
onClick={() => onSelectWord?.(w)}
className="border-b border-ctp-surface1 last:border-0 cursor-pointer hover:bg-ctp-surface1/50 transition-colors"
>
<td className="py-1.5 pr-3 font-mono tabular-nums text-ctp-peach text-xs">
#{w.frequencyRank!.toLocaleString()}
</td>
<td className="py-1.5 pr-3">
<span className="text-ctp-text font-medium">{w.headword}</span>
{(() => {
const reading = fullReading(w.headword, w.reading);
// `fullReading` normalizes katakana to hiragana, so we normalize the
// headword the same way before comparing — otherwise katakana-only
// entries like `カレー` would render `【かれー】`.
const normalizedHeadword = fullReading(w.headword, w.headword);
if (!reading || reading === normalizedHeadword) return null;
return (
<span className="text-ctp-subtext0 text-xs ml-1.5">【{reading}】</span>
);
})()}
</td>
<td className="py-1.5 pr-3">
{w.partOfSpeech && <PosBadge pos={w.partOfSpeech} />}
</td>
<td className="py-1.5 text-right font-mono tabular-nums text-ctp-blue text-xs">
{w.frequency}x
</td>
</tr>
))}
</tbody>
</table>
</div>
{totalPages > 1 && (
<div className="flex items-center justify-center gap-3 mt-3 text-xs">
<button
type="button"
disabled={page === 0}
onClick={() => setPage(page - 1)}
className="px-2 py-1 rounded bg-ctp-surface1 text-ctp-text disabled:opacity-30 hover:bg-ctp-surface2 transition-colors"
>
Prev
</button>
<span className="text-ctp-overlay2">
{page + 1} / {totalPages}
</span>
<button
type="button"
disabled={page >= totalPages - 1}
onClick={() => setPage(page + 1)}
className="px-2 py-1 rounded bg-ctp-surface1 text-ctp-text disabled:opacity-30 hover:bg-ctp-surface2 transition-colors"
>
Next
</button>
</div>
)}
</>
)}
</div>
);
}
@@ -1,89 +1,139 @@
import { useMemo } from 'react';
import type { KanjiEntry } from '../../types/stats';
import { useMemo, useState } from 'react';
import { formatNumber } from '../../lib/formatters';
import type { KanjiEntry } from '../../types/stats';
interface KanjiBreakdownProps {
kanji: KanjiEntry[];
knownWords: ReadonlySet<string>;
selectedKanjiId?: number | null;
onSelectKanji?: (entry: KanjiEntry) => void;
}
// Heat scale from rare (cool) to very frequent (warm). Catppuccin Macchiato.
const FREQ_TIERS = [
{ min: 0.85, color: 'text-ctp-peach', swatch: 'bg-ctp-peach', label: 'Very frequent' },
{ min: 0.6, color: 'text-ctp-yellow', swatch: 'bg-ctp-yellow', label: 'Frequent' },
{ min: 0.35, color: 'text-ctp-green', swatch: 'bg-ctp-green', label: 'Common' },
{ min: 0.15, color: 'text-ctp-teal', swatch: 'bg-ctp-teal', label: 'Occasional' },
{ min: 0, color: 'text-ctp-subtext0', swatch: 'bg-ctp-subtext0', label: 'Rare' },
] as const;
type KanjiFilter = 'all' | 'unknown';
function tierFor(intensity: number) {
return FREQ_TIERS.find((tier) => intensity >= tier.min) ?? FREQ_TIERS[FREQ_TIERS.length - 1]!;
// Rendering every kanji at once is slow to lay out and rarely what you want first.
const INITIAL_VISIBLE = 180;
const MIN_CELL_ALPHA = 0.08;
const MAX_CELL_ALPHA = 0.6;
const KANJI_CHARACTER = /\p{Script=Han}/u;
/** Every kanji appearing in at least one known word. */
export function kanjiInKnownWords(knownWords: Iterable<string>): Set<string> {
const found = new Set<string>();
for (const word of knownWords) {
for (const character of word) {
if (KANJI_CHARACTER.test(character)) found.add(character);
}
}
return found;
}
/** Square root, so common kanji still differ while rare ones stay visible. */
export function kanjiCellAlpha(frequency: number, maxFrequency: number): number {
const intensity = maxFrequency > 0 ? Math.sqrt(frequency / maxFrequency) : 0;
return MIN_CELL_ALPHA + (MAX_CELL_ALPHA - MIN_CELL_ALPHA) * intensity;
}
export function KanjiBreakdown({
kanji,
knownWords,
selectedKanjiId = null,
onSelectKanji,
}: KanjiBreakdownProps) {
const { totalOccurrences, maxLogFreq } = useMemo(() => {
let total = 0;
let maxFreq = 1;
for (const entry of kanji) {
total += entry.frequency;
if (entry.frequency > maxFreq) maxFreq = entry.frequency;
}
return { totalOccurrences: total, maxLogFreq: Math.log(maxFreq + 1) };
}, [kanji]);
const [filter, setFilter] = useState<KanjiFilter>('all');
const [showAll, setShowAll] = useState(false);
const hasKnownData = knownWords.size > 0;
const knownKanji = useMemo(() => kanjiInKnownWords(knownWords), [knownWords]);
const unknownKanji = useMemo(
() => kanji.filter((entry) => !knownKanji.has(entry.kanji)),
[kanji, knownKanji],
);
const maxFrequency = useMemo(
() => kanji.reduce((max, entry) => Math.max(max, entry.frequency), 1),
[kanji],
);
if (kanji.length === 0) return null;
const matching = filter === 'unknown' ? unknownKanji : kanji;
const visible = showAll ? matching : matching.slice(0, INITIAL_VISIBLE);
return (
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<h3 className="text-sm font-semibold text-ctp-text">
Kanji Encountered
<span className="ml-2 font-normal text-ctp-subtext0">
{formatNumber(kanji.length)} unique · {formatNumber(totalOccurrences)} seen
</span>
</h3>
<div className="flex items-center gap-1.5 text-[11px] text-ctp-subtext0">
<span>rare</span>
<div className="flex items-center gap-1">
{[...FREQ_TIERS].reverse().map((tier) => (
<span
key={tier.label}
className={`h-2 w-2 rounded-full ${tier.swatch}`}
title={tier.label}
/>
<section
aria-label="Kanji"
className="rounded-lg border border-ctp-surface1 bg-ctp-surface0 p-4"
>
<div className="mb-3 flex flex-wrap items-start justify-between gap-2">
<div>
<h3 className="text-xs font-semibold text-ctp-text">Kanji</h3>
<p className="mt-0.5 text-[11px] text-ctp-overlay2">
Shaded by how often you have seen it.
{hasKnownData ? ' Dashed means it is not in any word you know.' : ''}
</p>
</div>
{hasKnownData ? (
<div
role="group"
aria-label="Kanji filter"
className="flex rounded-lg border border-ctp-surface1 p-0.5 text-[11px]"
>
{(
[
['all', `All ${formatNumber(kanji.length)}`],
['unknown', `Not in a known word ${formatNumber(unknownKanji.length)}`],
] as const
).map(([value, label]) => (
<button
key={value}
type="button"
aria-pressed={filter === value}
onClick={() => setFilter(value)}
className={`rounded-md px-2 py-0.5 transition-colors ${
filter === value
? 'bg-ctp-surface2 text-ctp-text'
: 'text-ctp-overlay2 hover:text-ctp-subtext0'
}`}
>
{label}
</button>
))}
</div>
<span>frequent</span>
</div>
) : null}
</div>
<div className="flex flex-wrap gap-1">
{kanji.map((k) => {
// Log scale keeps the heavily-skewed frequency distribution readable.
const intensity = maxLogFreq > 0 ? Math.log(k.frequency + 1) / maxLogFreq : 0;
const tier = tierFor(intensity);
const selected = selectedKanjiId === k.kanjiId;
<div className="grid grid-cols-[repeat(auto-fill,minmax(2.25rem,1fr))] gap-1">
{visible.map((entry) => {
const selected = selectedKanjiId === entry.kanjiId;
const outsideKnown = hasKnownData && !knownKanji.has(entry.kanji);
return (
<button
type="button"
key={k.kanji}
className={`cursor-pointer rounded-md px-1.5 py-0.5 text-xl leading-none font-medium transition-colors duration-150 ${tier.color} ${
selected ? 'bg-ctp-surface2 ring-1 ring-ctp-lavender' : 'hover:bg-ctp-surface1'
}`}
title={`${k.kanji} — seen ${formatNumber(k.frequency)}×`}
aria-label={`${k.kanji} — seen ${k.frequency} times`}
key={entry.kanji}
lang="ja"
title={`${entry.kanji}: seen ${formatNumber(entry.frequency)} times`}
aria-label={`${entry.kanji}, seen ${entry.frequency} times`}
aria-pressed={selected}
onClick={() => onSelectKanji?.(k)}
onClick={() => onSelectKanji?.(entry)}
style={{
backgroundColor: `rgb(138 173 244 / ${kanjiCellAlpha(entry.frequency, maxFrequency).toFixed(2)})`,
}}
className={`flex aspect-square items-center justify-center rounded text-lg leading-none text-ctp-text transition hover:ring-1 hover:ring-ctp-blue ${
selected ? 'ring-1 ring-ctp-lavender' : ''
} ${outsideKnown ? 'outline-1 -outline-offset-1 outline-ctp-blue/70 outline-dashed' : ''}`}
>
{k.kanji}
{entry.kanji}
</button>
);
})}
</div>
</div>
{matching.length > INITIAL_VISIBLE ? (
<button
type="button"
onClick={() => setShowAll((value) => !value)}
className="mt-2 text-[11px] text-ctp-overlay2 transition-colors hover:text-ctp-blue"
>
{showAll ? 'Show fewer' : `Show all ${formatNumber(matching.length)}`}
</button>
) : null}
</section>
);
}
@@ -0,0 +1,47 @@
import { formatNumber } from '../../lib/formatters';
import type { VocabularyCoverageBand, VocabularyCoverageBandId } from '../../types/stats';
const BAND_LABELS: Record<VocabularyCoverageBandId, string> = {
top1k: 'Top 1k',
top5k: '1k to 5k',
top10k: '5k to 10k',
top20k: '10k to 20k',
};
/** How much of each frequency band you know, which points at where to mine next. */
export function VocabularyCoverage({ bands }: { bands: VocabularyCoverageBand[] }) {
return (
<div className="h-full rounded-lg border border-ctp-surface1 bg-ctp-surface0 p-4">
<h3 className="text-xs font-semibold text-ctp-text">Known Words by Frequency</h3>
<p className="mb-3 mt-0.5 text-[11px] text-ctp-overlay2">
Words you have seen in each band that are already in Anki.
</p>
<div className="space-y-3">
{bands.map(({ band, seen, known }) => {
const percent = seen > 0 ? Math.round((known / seen) * 100) : 0;
return (
<div
key={band}
className="grid grid-cols-[4.5rem_minmax(0,1fr)_auto] items-center gap-2 text-xs"
>
<span className="text-ctp-subtext0">{BAND_LABELS[band]}</span>
<div
role="img"
aria-label={`${percent}% known`}
className="h-2 overflow-hidden rounded-full bg-ctp-surface1/60"
>
<div
className="h-full rounded-full bg-ctp-blue"
style={{ width: `${seen > 0 ? (known / seen) * 100 : 0}%` }}
/>
</div>
<span className="whitespace-nowrap text-right text-[11px] tabular-nums text-ctp-overlay2">
{formatNumber(known)} of {formatNumber(seen)}, {percent}%
</span>
</div>
);
})}
</div>
</div>
);
}
@@ -0,0 +1,136 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { renderToStaticMarkup } from 'react-dom/server';
import { KanjiBreakdown, kanjiCellAlpha, kanjiInKnownWords } from './KanjiBreakdown';
import { VocabularyCoverage } from './VocabularyCoverage';
import { highlightWord } from './WordDetailPanel';
import { buildVocabularyGrowth } from './VocabularyTab';
import {
buildWordQuery,
defaultSortForView,
nextSort,
type WordExplorerState,
} from './word-explorer-query';
const baseState: WordExplorerState = {
view: 'all',
search: '',
hideKnown: false,
hideNames: true,
hideKana: true,
partOfSpeech: null,
sort: defaultSortForView('all'),
page: 2,
};
test('Worth mining means common and unknown, whatever the Hide known chip says', () => {
const query = buildWordQuery({ ...baseState, view: 'worth', sort: defaultSortForView('worth') });
assert.equal(query.hideKnown, true);
assert.equal(query.maxRank, 10_000);
assert.equal(query.sort, 'seen');
assert.equal(query.offset, 50);
});
test('Across titles needs two titles and sorts by them', () => {
const query = buildWordQuery({
...baseState,
view: 'titles',
sort: defaultSortForView('titles'),
});
assert.equal(query.minTitles, 2);
assert.equal(query.sort, 'titles');
});
test('searching ignores the view and filters so any word can be found', () => {
const query = buildWordQuery({
...baseState,
view: 'worth',
search: ' 猫 ',
hideKnown: true,
partOfSpeech: 'verb',
});
assert.equal(query.search, '猫');
assert.equal(query.hideKnown, false);
assert.equal(query.hideNames, false);
assert.equal(query.hideKana, false);
assert.equal(query.partOfSpeech, undefined);
assert.equal(query.maxRank, undefined);
});
test('nextSort flips the active column and starts new ones on their useful end', () => {
assert.deepEqual(nextSort({ sort: 'seen', descending: true }, 'seen'), {
sort: 'seen',
descending: false,
});
assert.deepEqual(nextSort({ sort: 'seen', descending: true }, 'rank'), {
sort: 'rank',
descending: false,
});
assert.deepEqual(nextSort({ sort: 'rank', descending: false }, 'titles'), {
sort: 'titles',
descending: true,
});
});
test('vocabulary growth is a running total', () => {
const growth = buildVocabularyGrowth([
{ epochDay: 20_000, wordCount: 5 },
{ epochDay: 20_010, wordCount: 2 },
]);
assert.deepEqual(
growth.map((point) => point.value),
[5, 7],
);
});
test('kanjiInKnownWords collects kanji and skips kana and Latin', () => {
assert.deepEqual(
[...kanjiInKnownWords(['見る', 'さん (used with adults)', '大丈夫'])].sort(),
['丈', '夫', '大', '見'].sort(),
);
});
test('kanjiCellAlpha keeps rare kanji visible and scales up to the most seen', () => {
assert.ok(kanjiCellAlpha(1, 7_000) > 0.08);
assert.ok(kanjiCellAlpha(900, 7_000) < kanjiCellAlpha(4_000, 7_000));
assert.equal(kanjiCellAlpha(7_000, 7_000), 0.6);
});
const KANJI = [
{ kanjiId: 1, kanji: '人', frequency: 100, firstSeen: 0, lastSeen: 0 },
{ kanjiId: 2, kanji: '鈴', frequency: 50, firstSeen: 0, lastSeen: 0 },
];
test('KanjiBreakdown reports the real totals and flags kanji outside known words', () => {
const markup = renderToStaticMarkup(
<KanjiBreakdown kanji={KANJI} knownWords={new Set(['人間'])} />,
);
assert.match(markup, /All 2/);
assert.match(markup, /Not in a known word 1/);
assert.equal(markup.match(/outline-dashed/g)?.length, 1);
assert.match(markup, /lang="ja"/);
});
test('KanjiBreakdown drops known-word features when there is no known-word data', () => {
const markup = renderToStaticMarkup(<KanjiBreakdown kanji={KANJI} knownWords={new Set()} />);
assert.doesNotMatch(markup, /Not in a known word/);
assert.doesNotMatch(markup, /outline-dashed/);
});
test('VocabularyCoverage shows each band as a share of the words seen in it', () => {
const markup = renderToStaticMarkup(
<VocabularyCoverage
bands={[
{ band: 'top1k', seen: 910, known: 690 },
{ band: 'top5k', seen: 0, known: 0 },
]}
/>,
);
assert.match(markup, /690 of 910, 76%/);
assert.match(markup, /0 of 0, 0%/);
});
test('highlightWord marks the longest form, whatever order forms arrive in', () => {
const markup = renderToStaticMarkup(<p>{highlightWord('食べたい', ['食べ', '食べた'])}</p>);
assert.match(markup, />食べた<\/mark>い/);
});
+102 -145
View File
@@ -1,45 +1,60 @@
import { useState, useMemo } from 'react';
import { useMemo, useState } from 'react';
import type { ExcludedWord } from '../../hooks/useExcludedWords';
import { useVocabulary } from '../../hooks/useVocabulary';
import { StatCard } from '../layout/StatCard';
import { WordList } from './WordList';
import { epochDayToDate, formatNumber } from '../../lib/formatters';
import { writeBooleanPreference } from '../../lib/preference-storage';
import type { DictionaryWordRow, KanjiEntry, StatsVocabularyCharts } from '../../types/stats';
import { SummaryTile } from '../layout/SummaryTile';
import { TrendChart } from '../trends/TrendChart';
import { DuplicateLineCleanup } from './DuplicateLineCleanup';
import { ExclusionManager } from './ExclusionManager';
import { KanjiBreakdown } from './KanjiBreakdown';
import { KanjiDetailPanel } from './KanjiDetailPanel';
import { ExclusionManager } from './ExclusionManager';
import { DuplicateLineCleanup } from './DuplicateLineCleanup';
import { epochDayToDate, formatNumber } from '../../lib/formatters';
import { TrendChart } from '../trends/TrendChart';
import { FrequencyRankTable } from './FrequencyRankTable';
import { CrossAnimeWordsTable } from './CrossAnimeWordsTable';
import type { ExcludedWord } from '../../hooks/useExcludedWords';
import type { KanjiEntry, VocabularyEntry } from '../../types/stats';
import { VocabularyCoverage } from './VocabularyCoverage';
import { WordExplorer } from './WordExplorer';
import { HIDE_NAMES_KEY, loadHideNames } from './word-explorer-query';
interface VocabularyTabProps {
onNavigateToAnime?: (animeId: number) => void;
onOpenWordDetail?: (wordId: number) => void;
excluded: ExcludedWord[];
isExcluded: (w: { headword: string; word: string; reading: string }) => boolean;
onToggleExclusion: (w: { headword: string; word: string; reading: string }) => void;
onRemoveExclusion: (w: ExcludedWord) => void;
onClearExclusions: () => void;
}
function isProperNoun(w: VocabularyEntry): boolean {
return w.pos2 === '固有名詞';
const GROWTH_COLOR = '#8aadf4';
/** Running total of words seen, one point per day a new word appeared. */
export function buildVocabularyGrowth(timeline: StatsVocabularyCharts['newWordsTimeline']) {
let total = 0;
return timeline.map((point) => {
total += point.wordCount;
return {
label: epochDayToDate(point.epochDay).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
}),
value: total,
};
});
}
export function VocabularyTab({
onNavigateToAnime,
onOpenWordDetail,
excluded,
isExcluded,
onToggleExclusion,
onRemoveExclusion,
onClearExclusions,
}: VocabularyTabProps) {
const {
words,
kanji,
knownWords,
summary,
charts,
coverage,
dataRevision,
loading,
error,
aggregatesError,
@@ -47,35 +62,15 @@ export function VocabularyTab({
reload,
} = useVocabulary();
const [selectedKanjiId, setSelectedKanjiId] = useState<number | null>(null);
const [hideNames, setHideNames] = useState(false);
const [hideNames, setHideNames] = useState(() => loadHideNames());
const [showExclusionManager, setShowExclusionManager] = useState(false);
const [showDuplicateLineCleanup, setShowDuplicateLineCleanup] = useState(false);
const hasNames = useMemo(() => words.some(isProperNoun), [words]);
const filteredWords = useMemo(() => {
let result = words;
if (hideNames) result = result.filter((w) => !isProperNoun(w));
if (excluded.length > 0) result = result.filter((w) => !isExcluded(w));
return result;
}, [words, hideNames, excluded, isExcluded]);
const chartData = useMemo(
() => ({
topWords: ((hideNames ? charts?.topWordsWithoutNames : charts?.topWords) ?? []).map(
(word) => ({
label: word.headword,
value: word.frequency,
}),
const growth = useMemo(
() =>
buildVocabularyGrowth(
(hideNames ? charts?.newWordsTimelineWithoutNames : charts?.newWordsTimeline) ?? [],
),
newWordsTimeline: (
(hideNames ? charts?.newWordsTimelineWithoutNames : charts?.newWordsTimeline) ?? []
).map((point) => ({
label: epochDayToDate(point.epochDay).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
}),
value: point.wordCount,
})),
}),
[charts, hideNames],
);
@@ -94,61 +89,53 @@ export function VocabularyTab({
);
}
const handleSelectWord = (entry: VocabularyEntry): void => {
onOpenWordDetail?.(entry.wordId);
const updateHideNames = (hide: boolean) => {
setHideNames(hide);
writeBooleanPreference(HIDE_NAMES_KEY, hide);
};
const handleBarClick = (headword: string): void => {
const match = (hideNames ? charts?.topWordsWithoutNames : charts?.topWords)?.find(
(word) => word.headword === headword,
);
if (match) onOpenWordDetail?.(match.wordId);
const hideWord = (row: DictionaryWordRow) => {
onToggleExclusion({ headword: row.headword, word: row.headword, reading: row.reading });
};
const openKanjiDetail = (entry: KanjiEntry) => setSelectedKanjiId(entry.kanjiId);
const openKanjiDetail = (entry: KanjiEntry): void => {
setSelectedKanjiId(entry.kanjiId);
};
const displayedSummary = hideNames
? {
uniqueWords: summary?.uniqueWordsWithoutNames ?? 0,
newThisWeek: summary?.newThisWeekWithoutNames ?? 0,
knownWordCount: summary?.knownWordCountWithoutNames ?? null,
}
: {
uniqueWords: summary?.uniqueWords ?? 0,
newThisWeek: summary?.newThisWeek ?? 0,
knownWordCount: summary?.knownWordCount ?? null,
};
const uniqueWords = (hideNames ? summary?.uniqueWordsWithoutNames : summary?.uniqueWords) ?? 0;
const newThisWeek = (hideNames ? summary?.newThisWeekWithoutNames : summary?.newThisWeek) ?? 0;
const knownWordCount =
(hideNames ? summary?.knownWordCountWithoutNames : summary?.knownWordCount) ?? null;
const knownPercent =
knownWordCount !== null && uniqueWords > 0
? Math.round((knownWordCount / uniqueWords) * 100)
: 0;
return (
<div className="space-y-4">
<div className="grid grid-cols-2 xl:grid-cols-4 gap-3">
<StatCard
label="Unique Words"
value={summary ? formatNumber(displayedSummary.uniqueWords) : '…'}
color="text-ctp-blue"
<section
aria-label="Vocabulary summary"
className="grid grid-cols-2 gap-4 rounded-lg border border-ctp-surface1 bg-ctp-surface0 p-4 lg:grid-cols-4"
>
<SummaryTile
label="Words seen"
value={summary ? formatNumber(uniqueWords) : '…'}
detail={hideNames ? 'Dictionary forms, names excluded' : 'Dictionary forms'}
/>
{displayedSummary.knownWordCount !== null ? (
<StatCard
label="Known Words"
value={`${formatNumber(displayedSummary.knownWordCount)} (${displayedSummary.uniqueWords > 0 ? Math.round((displayedSummary.knownWordCount / displayedSummary.uniqueWords) * 100) : 0}%)`}
color="text-ctp-green"
{knownWordCount !== null ? (
<SummaryTile
label="Known in Anki"
value={formatNumber(knownWordCount)}
detail={`${knownPercent}% of words seen`}
/>
) : knownWords.size > 0 ? (
<StatCard label="Known Words" value="…" color="text-ctp-green" />
) : null}
<StatCard
label="Unique Kanji"
<SummaryTile
label="New in the last 7 days"
value={summary ? `+${formatNumber(newThisWeek)}` : '…'}
detail={null}
/>
<SummaryTile
label="Kanji seen"
value={summary ? formatNumber(summary.uniqueKanji) : '…'}
color="text-ctp-teal"
detail={null}
/>
<StatCard
label="New This Week"
value={summary ? `+${formatNumber(displayedSummary.newThisWeek)}` : '…'}
color="text-ctp-mauve"
/>
</div>
</section>
{aggregatesError && (
<p className="text-xs text-ctp-red" role="alert">
@@ -163,54 +150,11 @@ export function VocabularyTab({
</p>
)}
<div className="flex items-center justify-end gap-3">
{hasNames && (
<button
type="button"
onClick={() => setHideNames(!hideNames)}
className={`shrink-0 px-3 py-2 rounded-lg text-xs transition-colors border ${
hideNames
? 'bg-ctp-surface2 text-ctp-text border-ctp-blue/50'
: 'bg-ctp-surface0 text-ctp-overlay2 border-ctp-surface1 hover:text-ctp-subtext0'
}`}
>
Hide Names
</button>
)}
<button
type="button"
onClick={() => setShowDuplicateLineCleanup(true)}
className="shrink-0 rounded-lg border border-ctp-surface1 bg-ctp-surface0 px-3 py-2 text-xs text-ctp-overlay2 transition-colors hover:text-ctp-subtext0"
>
Duplicates
</button>
<button
type="button"
onClick={() => setShowExclusionManager(true)}
className={`shrink-0 px-3 py-2 rounded-lg text-xs transition-colors border ${
excluded.length > 0
? 'bg-ctp-surface2 text-ctp-text border-ctp-red/50'
: 'bg-ctp-surface0 text-ctp-overlay2 border-ctp-surface1 hover:text-ctp-subtext0'
}`}
>
Exclusions{excluded.length > 0 && ` (${excluded.length})`}
</button>
</div>
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
<TrendChart
title="Top Repeated Words"
data={chartData.topWords}
color="#8aadf4"
type="bar"
onBarClick={handleBarClick}
/>
<TrendChart
title="New Words by Day"
data={chartData.newWordsTimeline}
color="#c6a0f6"
type="line"
/>
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
{coverage.length > 0 ? <VocabularyCoverage bands={coverage} /> : null}
<div className={coverage.length > 0 ? '' : 'xl:col-span-2'}>
<TrendChart title="Vocabulary Growth" data={growth} color={GROWTH_COLOR} type="line" />
</div>
</div>
{charts && !charts.ready && (
@@ -219,26 +163,39 @@ export function VocabularyTab({
</p>
)}
<FrequencyRankTable
words={filteredWords}
knownWords={knownWords}
onSelectWord={handleSelectWord}
<WordExplorer
hasKnownData={knownWords.size > 0}
hideNames={hideNames}
onHideNamesChange={updateHideNames}
refreshKey={dataRevision}
onOpenWord={(wordId) => onOpenWordDetail?.(wordId)}
onHideWord={hideWord}
/>
<CrossAnimeWordsTable
words={filteredWords}
knownWords={knownWords}
onSelectWord={handleSelectWord}
/>
<WordList words={filteredWords} selectedKey={null} onSelectWord={handleSelectWord} />
<KanjiBreakdown
kanji={kanji}
knownWords={knownWords}
selectedKanjiId={selectedKanjiId}
onSelectKanji={openKanjiDetail}
/>
<div className="flex justify-end gap-4 text-[11px] text-ctp-overlay2">
<button
type="button"
onClick={() => setShowExclusionManager(true)}
className="transition-colors hover:text-ctp-text"
>
Exclusions{excluded.length > 0 && ` (${excluded.length})`}
</button>
<button
type="button"
onClick={() => setShowDuplicateLineCleanup(true)}
className="transition-colors hover:text-ctp-text"
>
Clean up duplicates
</button>
</div>
<KanjiDetailPanel
kanjiId={selectedKanjiId}
onClose={() => setSelectedKanjiId(null)}
@@ -4,11 +4,14 @@ import { apiClient } from '../../lib/api-client';
import { epochMsFromDbTimestamp, formatNumber, formatRelativeDate } from '../../lib/formatters';
import {
buildStatsMineCardParams,
describeStreamedLines,
getStatsMineCardError,
getStatsMineCardUnavailableReason,
} from '../../lib/mining';
import { fullReading } from '../../lib/reading-utils';
import type { VocabularyOccurrenceEntry } from '../../types/stats';
import { MineUnavailableNote } from '../common/MineUnavailableNote';
import { SummaryTile } from '../layout/SummaryTile';
import { PosBadge } from './pos-helpers';
const INITIAL_PAGE_SIZE = 5;
@@ -26,8 +29,9 @@ interface WordDetailPanelProps {
onToggleExclusion?: (w: { headword: string; word: string; reading: string }) => void;
}
function highlightWord(text: string, words: string[]): React.ReactNode {
const needles = words.filter(Boolean);
export function highlightWord(text: string, words: string[]): React.ReactNode {
// Longest first: regex alternation takes the first match, so 食べ would cut 食べた short.
const needles = words.filter(Boolean).sort((left, right) => right.length - left.length);
if (needles.length === 0) return text;
const escaped = needles.map((w) => w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
@@ -49,6 +53,11 @@ function highlightWord(text: string, words: string[]): React.ReactNode {
);
}
function displayReading(detail: { headword: string; dictionaryReading: string }): string {
const reading = fullReading(detail.headword, detail.dictionaryReading);
return reading === fullReading(detail.headword, detail.headword) ? '' : reading;
}
function formatSegment(ms: number | null): string {
if (ms == null || !Number.isFinite(ms)) return '--:--';
const totalSeconds = Math.max(0, Math.floor(ms / 1000));
@@ -88,8 +97,6 @@ export function WordDetailPanel({
requestIdRef.current++;
}, [wordId]);
if (wordId === null) return null;
const loadOccurrences = async (
detail: NonNullable<typeof data>['detail'],
offset: number,
@@ -110,6 +117,7 @@ export function WordDetailPanel({
detail.reading,
limit,
offset,
true,
);
if (reqId !== requestIdRef.current) return;
setOccurrences((prev) => (append ? [...prev, ...rows] : rows));
@@ -129,10 +137,16 @@ export function WordDetailPanel({
}
};
const handleShowOccurrences = () => {
if (!data) return;
// The examples are where mining happens, so they load with the word. The detail
// may still be the previous word's while the new one loads.
const loadedWordId = data?.detail.wordId ?? null;
useEffect(() => {
if (!data || loadedWordId !== wordId) return;
void loadOccurrences(data.detail, 0, INITIAL_PAGE_SIZE, false);
};
// Only a newly opened word should reload examples.
}, [loadedWordId, wordId]);
if (wordId === null) return null;
const handleLoadMore = () => {
if (!data || occLoadingMore || !hasMore) return;
@@ -182,6 +196,11 @@ export function WordDetailPanel({
}
};
const dictionaryReading = data ? displayReading(data.detail) : '';
const streamedLinesNotice = data
? describeStreamedLines(data.detail.lineCount, data.detail.streamLineCount)
: null;
return (
<div className="fixed inset-0 z-40 flex items-center justify-center p-4">
<button
@@ -201,30 +220,19 @@ export function WordDetailPanel({
{error && <div className="mt-2 text-sm text-ctp-red">Error: {error}</div>}
{data && (
<>
<h2 className="mt-1 truncate text-3xl font-semibold text-ctp-text">
<h2 lang="ja" className="mt-1 truncate text-3xl font-semibold text-ctp-text">
{data.detail.headword}
</h2>
<div className="mt-1 text-sm text-ctp-subtext0">
{fullReading(data.detail.headword, data.detail.reading) || data.detail.word}
</div>
<div className="mt-2 flex flex-wrap gap-1.5">
{data.detail.partOfSpeech && <PosBadge pos={data.detail.partOfSpeech} />}
{data.detail.pos1 && data.detail.pos1 !== data.detail.partOfSpeech && (
<span className="rounded-full bg-ctp-surface1 px-2 py-0.5 text-[11px] text-ctp-subtext0">
{data.detail.pos1}
</span>
)}
{data.detail.pos2 && (
<span className="rounded-full bg-ctp-surface1 px-2 py-0.5 text-[11px] text-ctp-subtext0">
{data.detail.pos2}
</span>
)}
{data.detail.pos3 && (
<span className="rounded-full bg-ctp-surface1 px-2 py-0.5 text-[11px] text-ctp-subtext0">
{data.detail.pos3}
</span>
)}
</div>
{dictionaryReading && (
<div lang="ja" className="mt-1 text-sm text-ctp-subtext0">
{dictionaryReading}
</div>
)}
{data.detail.partOfSpeech && (
<div className="mt-2">
<PosBadge pos={data.detail.partOfSpeech} />
</div>
)}
</>
)}
</div>
@@ -255,27 +263,62 @@ export function WordDetailPanel({
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-5">
{data && (
<>
<div className="grid grid-cols-3 gap-3">
<div className="rounded-lg bg-ctp-surface0 p-3 text-center">
<div className="text-lg font-bold text-ctp-blue">
{formatNumber(data.detail.frequency)}
</div>
<div className="text-[11px] text-ctp-overlay1 uppercase">Frequency</div>
</div>
<div className="rounded-lg bg-ctp-surface0 p-3 text-center">
<div className="text-sm font-medium text-ctp-green">
{formatRelativeDate(epochMsFromDbTimestamp(data.detail.firstSeen))}
</div>
<div className="text-[11px] text-ctp-overlay1 uppercase">First Seen</div>
</div>
<div className="rounded-lg bg-ctp-surface0 p-3 text-center">
<div className="text-sm font-medium text-ctp-mauve">
{formatRelativeDate(epochMsFromDbTimestamp(data.detail.lastSeen))}
</div>
<div className="text-[11px] text-ctp-overlay1 uppercase">Last Seen</div>
</div>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<SummaryTile
label="Seen"
value={formatNumber(data.detail.frequency)}
detail={
data.detail.formCount > 1
? `across ${formatNumber(data.detail.formCount)} forms`
: null
}
/>
<SummaryTile
label="Frequency rank"
value={
data.detail.frequencyRank === null
? '—'
: formatNumber(data.detail.frequencyRank)
}
detail={null}
/>
<SummaryTile
label="First seen"
value={formatRelativeDate(epochMsFromDbTimestamp(data.detail.firstSeen))}
detail={null}
compact
/>
<SummaryTile
label="Last seen"
value={formatRelativeDate(epochMsFromDbTimestamp(data.detail.lastSeen))}
detail={null}
compact
/>
</div>
{data.detail.formCount > 1 && (
<section>
<h3 className="text-xs font-semibold uppercase tracking-wide text-ctp-overlay1 mb-2">
Forms
</h3>
<div lang="ja" className="flex flex-wrap gap-x-3 gap-y-1 text-sm">
{data.detail.forms.map((form) => (
<span key={form.wordId} className="text-ctp-text">
{form.word}
<span lang="en" className="ml-1 text-xs text-ctp-overlay2 tabular-nums">
{formatNumber(form.seen)}
</span>
</span>
))}
{data.detail.formCount > data.detail.forms.length && (
<span lang="en" className="text-xs text-ctp-overlay2">
+{formatNumber(data.detail.formCount - data.detail.forms.length)} more
</span>
)}
</div>
</section>
)}
{data.animeAppearances.length > 0 && (
<section>
<h3 className="text-xs font-semibold uppercase tracking-wide text-ctp-overlay1 mb-2">
@@ -328,11 +371,13 @@ export function WordDetailPanel({
<button
key={sw.wordId}
type="button"
className="inline-flex items-center gap-1 rounded px-2 py-1 text-xs text-ctp-blue bg-ctp-blue/10 transition hover:ring-1 hover:ring-ctp-blue"
className="inline-flex items-center gap-1.5 rounded px-2 py-1 text-xs text-ctp-text bg-ctp-surface0 transition hover:ring-1 hover:ring-ctp-blue"
onClick={() => onSelectWord?.(sw.wordId)}
>
{sw.headword}
<span className="opacity-60">({formatNumber(sw.frequency)})</span>
<span lang="ja">{sw.headword}</span>
<span className="text-ctp-overlay2 tabular-nums">
{formatNumber(sw.frequency)}
</span>
</button>
))}
</div>
@@ -343,19 +388,15 @@ export function WordDetailPanel({
<h3 className="text-xs font-semibold uppercase tracking-wide text-ctp-overlay1 mb-2">
Example Lines
</h3>
{!occLoaded && !occLoading && (
<button
type="button"
className="w-full rounded-lg border border-ctp-surface2 bg-ctp-surface0 px-4 py-2 text-sm font-medium text-ctp-text transition hover:border-ctp-blue hover:text-ctp-blue"
onClick={handleShowOccurrences}
>
Load example lines
</button>
)}
{occLoading && (
<div className="text-sm text-ctp-overlay2">Loading occurrences...</div>
)}
{occError && <div className="text-sm text-ctp-red">Error: {occError}</div>}
{streamedLinesNotice && (
<p className="mb-2 text-xs text-ctp-peach" role="note">
{streamedLinesNotice}
</p>
)}
{occLoaded && !occLoading && occurrences.length === 0 && (
<div className="text-sm text-ctp-overlay2">
No example lines tracked yet. Lines are stored for sessions recorded after the
@@ -393,54 +434,49 @@ export function WordDetailPanel({
const wordStatus = mineStatus[`${baseKey}-word`];
const sentenceStatus = mineStatus[`${baseKey}-sentence`];
const audioStatus = mineStatus[`${baseKey}-audio`];
if (unavailableReason) {
return <MineUnavailableNote reason={unavailableReason} />;
}
return (
<>
<button
type="button"
title={unavailableReason ?? 'Mine this word from video clip'}
title="Mine this word from video clip"
className="rounded border border-ctp-surface2 px-1.5 py-0.5 text-[10px] font-medium text-ctp-subtext0 transition hover:border-ctp-mauve hover:text-ctp-mauve disabled:cursor-not-allowed disabled:opacity-60"
disabled={wordStatus?.loading || !!unavailableReason}
disabled={wordStatus?.loading}
onClick={() => void handleMine(occ, 'word')}
>
{wordStatus?.loading
? 'Mining...'
: wordStatus?.success
? 'Mined!'
: unavailableReason
? 'Unavailable'
: 'Mine Word'}
: 'Mine Word'}
</button>
<button
type="button"
title={
unavailableReason ?? 'Mine this sentence from video clip'
}
title="Mine this sentence from video clip"
className="rounded border border-ctp-surface2 px-1.5 py-0.5 text-[10px] font-medium text-ctp-subtext0 transition hover:border-ctp-green hover:text-ctp-green disabled:cursor-not-allowed disabled:opacity-60"
disabled={sentenceStatus?.loading || !!unavailableReason}
disabled={sentenceStatus?.loading}
onClick={() => void handleMine(occ, 'sentence')}
>
{sentenceStatus?.loading
? 'Mining...'
: sentenceStatus?.success
? 'Mined!'
: unavailableReason
? 'Unavailable'
: 'Mine Sentence'}
: 'Mine Sentence'}
</button>
<button
type="button"
title={unavailableReason ?? 'Mine this line as audio-only card'}
title="Mine this line as audio-only card"
className="rounded border border-ctp-surface2 px-1.5 py-0.5 text-[10px] font-medium text-ctp-subtext0 transition hover:border-ctp-blue hover:text-ctp-blue disabled:cursor-not-allowed disabled:opacity-60"
disabled={audioStatus?.loading || !!unavailableReason}
disabled={audioStatus?.loading}
onClick={() => void handleMine(occ, 'audio')}
>
{audioStatus?.loading
? 'Mining...'
: audioStatus?.success
? 'Mined!'
: unavailableReason
? 'Unavailable'
: 'Mine Audio'}
: 'Mine Audio'}
</button>
</>
);
@@ -455,8 +491,14 @@ export function WordDetailPanel({
<div className="mt-1 text-[10px] text-ctp-red">{errors[0]}</div>
) : null;
})()}
<p className="mt-3 rounded-lg bg-ctp-base/70 px-3 py-3 text-sm leading-6 text-ctp-text">
{highlightWord(occ.text, [data!.detail.headword, data!.detail.word])}
<p
lang="ja"
className="mt-3 rounded-lg bg-ctp-base/70 px-3 py-3 text-sm leading-6 text-ctp-text"
>
{highlightWord(occ.text, [
data!.detail.headword,
...data!.detail.forms.map((form) => form.word),
])}
</p>
</article>
))}
@@ -0,0 +1,463 @@
import { Fragment, useEffect, useMemo, useState } from 'react';
import { useDictionaryWords } from '../../hooks/useDictionaryWords';
import {
epochDayToDate,
epochMsFromDbTimestamp,
formatNumber,
localDayFromMs,
} from '../../lib/formatters';
import {
readBooleanPreference,
writeBooleanPreference,
writeStringPreference,
} from '../../lib/preference-storage';
import { fullReading } from '../../lib/reading-utils';
import type {
DictionaryWordRow,
StatsDictionaryWordPos,
StatsDictionaryWordSort,
} from '../../types/stats';
import {
HIDE_KANA_KEY,
HIDE_KNOWN_KEY,
WORD_PAGE_SIZE,
WORD_VIEW_KEY,
WORD_VIEWS,
buildWordQuery,
defaultSortForView,
loadWordView,
nextSort,
type WordSort,
type WordView,
} from './word-explorer-query';
interface WordExplorerProps {
hasKnownData: boolean;
hideNames: boolean;
onHideNamesChange: (hide: boolean) => void;
/** Changes when server-side word data may have changed. */
refreshKey: number;
onOpenWord: (wordId: number) => void;
onHideWord: (word: DictionaryWordRow) => void;
}
const VIEW_LABELS: Record<WordView, string> = {
worth: 'Worth mining',
all: 'All words',
titles: 'Across titles',
newest: 'Newest',
};
const POS_FILTERS: Array<{ value: StatsDictionaryWordPos; label: string }> = [
{ value: 'noun', label: 'Nouns' },
{ value: 'verb', label: 'Verbs' },
{ value: 'adjective', label: 'Adjectives' },
];
const POS_LABELS: Record<string, string> = {
i_adjective: 'adjective',
na_adjective: 'adjective',
auxiliary_verb: 'auxiliary',
bound_auxiliary: 'auxiliary',
};
const SEARCH_DEBOUNCE_MS = 250;
const COLUMNS: Array<{ id: StatsDictionaryWordSort; label: string; align: 'left' | 'right' }> = [
{ id: 'headword', label: 'Word', align: 'left' },
{ id: 'rank', label: 'Rank', align: 'right' },
{ id: 'seen', label: 'Seen', align: 'right' },
{ id: 'titles', label: 'Titles', align: 'right' },
{ id: 'firstSeen', label: 'First seen', align: 'right' },
];
function ToggleChip({
label,
pressed,
onToggle,
}: {
label: string;
pressed: boolean;
onToggle: () => void;
}) {
return (
<button
type="button"
aria-pressed={pressed}
onClick={onToggle}
className={`rounded-full border px-2.5 py-0.5 text-[11px] transition-colors ${
pressed
? 'border-ctp-blue/70 text-ctp-text'
: 'border-ctp-surface1 text-ctp-overlay2 hover:text-ctp-subtext0'
}`}
>
{label}
</button>
);
}
function formatFirstSeen(timestamp: number): string {
const day = localDayFromMs(epochMsFromDbTimestamp(timestamp));
return epochDayToDate(day).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
});
}
function rowReading(row: DictionaryWordRow): string {
const reading = fullReading(row.headword, row.reading);
// `fullReading` normalizes katakana, so カレー must not render its own hiragana.
return reading === fullReading(row.headword, row.headword) ? '' : reading;
}
function describeResults(view: WordView, searching: boolean, hasKnownData: boolean): string {
if (searching) return 'matching words';
if (view === 'worth') return hasKnownData ? 'common words not yet mined' : 'common words';
if (view === 'titles') return 'words across titles';
return 'words';
}
/** The one word list: server-filtered, so it reaches every word you have seen. */
export function WordExplorer({
hasKnownData,
hideNames,
onHideNamesChange,
refreshKey,
onOpenWord,
onHideWord,
}: WordExplorerProps) {
const [view, setView] = useState<WordView>(() => loadWordView());
const [sort, setSort] = useState<WordSort>(() => defaultSortForView(loadWordView()));
const [searchDraft, setSearchDraft] = useState('');
const [search, setSearch] = useState('');
const [hideKnown, setHideKnown] = useState(() => readBooleanPreference(HIDE_KNOWN_KEY, true));
const [hideKana, setHideKana] = useState(() => readBooleanPreference(HIDE_KANA_KEY, true));
const [partOfSpeech, setPartOfSpeech] = useState<StatsDictionaryWordPos | null>(null);
const [page, setPage] = useState(0);
const [expandedWord, setExpandedWord] = useState<string | null>(null);
useEffect(() => {
const timer = setTimeout(() => {
setSearch(searchDraft);
setPage(0);
}, SEARCH_DEBOUNCE_MS);
return () => clearTimeout(timer);
}, [searchDraft]);
const query = useMemo(
() =>
buildWordQuery({
view,
search,
hideKnown: hasKnownData && hideKnown,
hideNames,
hideKana,
partOfSpeech,
sort,
page,
}),
[view, search, hasKnownData, hideKnown, hideNames, hideKana, partOfSpeech, sort, page],
);
const { page: results, loading, error } = useDictionaryWords(query, refreshKey);
const selectView = (next: WordView) => {
setView(next);
setSort(defaultSortForView(next));
setPage(0);
writeStringPreference(WORD_VIEW_KEY, next);
};
const applyFilter = (update: () => void) => {
update();
setPage(0);
};
const total = results?.total ?? 0;
const rows = results?.rows ?? [];
const firstRow = total === 0 ? 0 : page * WORD_PAGE_SIZE + 1;
const lastRow = Math.min((page + 1) * WORD_PAGE_SIZE, total);
const searching = search.trim() !== '';
return (
<section
aria-label="Words"
className="rounded-lg border border-ctp-surface1 bg-ctp-surface0 p-4"
>
<div className="flex flex-wrap items-center gap-2">
<div
role="group"
aria-label="Word list view"
className="flex rounded-lg border border-ctp-surface1 p-0.5"
>
{WORD_VIEWS.map((option) => (
<button
key={option}
type="button"
aria-pressed={view === option}
onClick={() => selectView(option)}
className={`rounded-md px-2.5 py-1 text-xs transition-colors ${
view === option
? 'bg-ctp-surface2 text-ctp-text'
: 'text-ctp-overlay2 hover:text-ctp-subtext0'
}`}
>
{VIEW_LABELS[option]}
</button>
))}
</div>
<input
type="search"
aria-label="Search all words"
placeholder="Search all words..."
value={searchDraft}
onChange={(event) => setSearchDraft(event.target.value)}
className="min-w-40 flex-1 rounded-lg border border-ctp-surface1 bg-transparent px-2.5 py-1 text-xs text-ctp-text placeholder:text-ctp-overlay2 focus:border-ctp-blue focus:outline-none"
/>
</div>
{searching ? null : (
<div className="mt-2 flex flex-wrap items-center gap-1.5">
{hasKnownData && view !== 'worth' ? (
<ToggleChip
label="Hide known"
pressed={hideKnown}
onToggle={() =>
applyFilter(() => {
setHideKnown(!hideKnown);
writeBooleanPreference(HIDE_KNOWN_KEY, !hideKnown);
})
}
/>
) : null}
<ToggleChip
label="Hide names"
pressed={hideNames}
onToggle={() => applyFilter(() => onHideNamesChange(!hideNames))}
/>
<ToggleChip
label="Hide kana"
pressed={hideKana}
onToggle={() =>
applyFilter(() => {
setHideKana(!hideKana);
writeBooleanPreference(HIDE_KANA_KEY, !hideKana);
})
}
/>
<span aria-hidden="true" className="mx-1 h-3 w-px bg-ctp-surface1" />
{POS_FILTERS.map(({ value, label }) => (
<ToggleChip
key={value}
label={label}
pressed={partOfSpeech === value}
onToggle={() =>
applyFilter(() => setPartOfSpeech(partOfSpeech === value ? null : value))
}
/>
))}
</div>
)}
{error ? (
<div role="alert" className="mt-3 text-xs text-ctp-red">
Words failed to load: {error}
</div>
) : null}
<div
aria-busy={loading}
className={`mt-3 overflow-x-auto transition-opacity ${loading && results ? 'opacity-60' : ''}`}
>
<table className="w-full text-xs">
<thead>
<tr className="border-b border-ctp-surface1 text-ctp-overlay2">
{COLUMNS.map((column) => {
const active = sort.sort === column.id;
return (
<th
key={column.id}
scope="col"
aria-sort={active ? (sort.descending ? 'descending' : 'ascending') : undefined}
className={`pb-2 pl-3 font-medium first:pl-0 ${
column.align === 'right' ? 'text-right' : 'text-left'
}`}
>
<button
type="button"
onClick={() => {
setSort(nextSort(sort, column.id));
setPage(0);
}}
className={`whitespace-nowrap transition-colors hover:text-ctp-text ${
active ? 'text-ctp-text' : ''
}`}
>
{column.label}
{active ? (sort.descending ? ' ▼' : ' ▲') : ''}
</button>
</th>
);
})}
<th scope="col" className="pb-2 pl-3">
<span className="sr-only">Actions</span>
</th>
</tr>
</thead>
<tbody>
{rows.map((row) => {
const expanded = expandedWord === row.headword;
const reading = rowReading(row);
return (
<Fragment key={row.headword}>
<tr className="border-b border-ctp-surface1/50 transition-colors last:border-0 hover:bg-ctp-surface1/30">
<td className="py-1.5">
<div className="flex min-w-0 items-baseline gap-2">
{row.formCount > 1 ? (
<button
type="button"
aria-expanded={expanded}
aria-label={`Show forms of ${row.headword}`}
onClick={() => setExpandedWord(expanded ? null : row.headword)}
className={`w-3 shrink-0 text-[9px] text-ctp-overlay2 transition-transform hover:text-ctp-text ${
expanded ? 'rotate-90' : ''
}`}
>
▶
</button>
) : (
<span aria-hidden="true" className="w-3 shrink-0" />
)}
<button
type="button"
lang="ja"
onClick={() => onOpenWord(row.wordId)}
className="text-left text-[15px] font-medium text-ctp-text transition-colors hover:text-ctp-blue"
>
{row.headword}
</button>
{reading ? (
<span lang="ja" className="text-[11px] text-ctp-overlay2">
{reading}
</span>
) : null}
<span className="text-[11px] text-ctp-overlay1">
{row.partOfSpeech
? (POS_LABELS[row.partOfSpeech] ?? row.partOfSpeech.replace(/_/g, ' '))
: ''}
</span>
{row.known ? (
<span className="text-[11px] text-ctp-green">Known</span>
) : null}
{row.isName ? (
<span className="text-[11px] text-ctp-overlay1">Name</span>
) : null}
{row.streamOnly ? (
<span
title="Every line with this word came from a stream, so it can't be mined yet."
className="text-[11px] whitespace-nowrap text-ctp-peach"
>
Stream only
</span>
) : null}
</div>
</td>
<td className="py-1.5 pl-3 text-right tabular-nums text-ctp-subtext0">
{row.rank === null ? '—' : formatNumber(row.rank)}
</td>
<td className="py-1.5 pl-3 text-right tabular-nums text-ctp-text">
<div className="flex items-center justify-end gap-2">
{formatNumber(row.seen)}
<span
aria-hidden="true"
className="block h-1 w-12 overflow-hidden rounded-full bg-ctp-surface1/60"
>
<span
className="block h-full rounded-full bg-ctp-blue"
style={{
width: `${results && results.maxSeen > 0 ? (row.seen / results.maxSeen) * 100 : 0}%`,
}}
/>
</span>
</div>
</td>
<td className="py-1.5 pl-3 text-right tabular-nums text-ctp-subtext0">
{formatNumber(row.titles)}
</td>
<td className="whitespace-nowrap py-1.5 pl-3 text-right tabular-nums text-ctp-overlay2">
{formatFirstSeen(row.firstSeen)}
</td>
<td className="py-1.5 pl-3 text-right">
<button
type="button"
title="Exclude this word from stats. Restore it from Exclusions."
aria-label={`Hide ${row.headword}`}
onClick={() => onHideWord(row)}
className="text-[11px] text-ctp-overlay2 transition-colors hover:text-ctp-text"
>
Hide
</button>
</td>
</tr>
{expanded ? (
<tr className="border-b border-ctp-surface1/50">
<td colSpan={6} className="pb-2 pl-5">
<div lang="ja" className="flex flex-wrap gap-x-3 gap-y-1">
{row.forms.map((form) => (
<span key={form.wordId} className="text-ctp-subtext0">
{form.word}
<span lang="en" className="ml-1 tabular-nums text-ctp-overlay2">
{formatNumber(form.seen)}
</span>
</span>
))}
{row.formCount > row.forms.length ? (
<span lang="en" className="text-ctp-overlay2">
+{formatNumber(row.formCount - row.forms.length)} more
</span>
) : null}
</div>
</td>
</tr>
) : null}
</Fragment>
);
})}
</tbody>
</table>
{results && rows.length === 0 ? (
<div className="py-6 text-center text-xs text-ctp-overlay2">
{searching
? 'No words match that search. Hidden words are managed under Exclusions.'
: 'No words match these filters.'}
</div>
) : null}
</div>
<div className="mt-3 flex items-center justify-between text-[11px] text-ctp-overlay2">
<span role="status">
{results
? `${formatNumber(firstRow)} to ${formatNumber(lastRow)} of ${formatNumber(total)} ${describeResults(view, searching, hasKnownData)}`
: 'Loading...'}
</span>
<span className="flex items-center gap-3">
<button
type="button"
disabled={page === 0}
onClick={() => setPage(page - 1)}
className="transition-colors hover:text-ctp-text disabled:opacity-40"
>
Prev
</button>
<button
type="button"
disabled={lastRow >= total}
onClick={() => setPage(page + 1)}
className="transition-colors hover:text-ctp-text disabled:opacity-40"
>
Next
</button>
</span>
</div>
</section>
);
}
@@ -1,130 +0,0 @@
import { useMemo, useState } from 'react';
import type { VocabularyEntry } from '../../types/stats';
import { PosBadge } from './pos-helpers';
interface WordListProps {
words: VocabularyEntry[];
selectedKey?: string | null;
onSelectWord?: (word: VocabularyEntry) => void;
search?: string;
}
type SortKey = 'frequency' | 'lastSeen' | 'firstSeen';
function toWordKey(word: VocabularyEntry): string {
return `${word.headword}\u0000${word.word}\u0000${word.reading}`;
}
const PAGE_SIZE = 100;
export function WordList({ words, selectedKey = null, onSelectWord, search = '' }: WordListProps) {
const [sortBy, setSortBy] = useState<SortKey>('frequency');
const [page, setPage] = useState(0);
const titleBySort: Record<SortKey, string> = {
frequency: 'Most Seen Words',
lastSeen: 'Recently Seen Words',
firstSeen: 'First Seen Words',
};
const filtered = useMemo(() => {
const needle = search.trim().toLowerCase();
if (!needle) return words;
return words.filter(
(w) =>
w.headword.toLowerCase().includes(needle) ||
w.word.toLowerCase().includes(needle) ||
w.reading.toLowerCase().includes(needle),
);
}, [words, search]);
const sorted = useMemo(() => {
const copy = [...filtered];
if (sortBy === 'frequency') copy.sort((a, b) => b.frequency - a.frequency);
else if (sortBy === 'lastSeen') copy.sort((a, b) => b.lastSeen - a.lastSeen);
else copy.sort((a, b) => b.firstSeen - a.firstSeen);
return copy;
}, [filtered, sortBy]);
const totalPages = Math.ceil(sorted.length / PAGE_SIZE);
const paged = sorted.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
const maxFreq = words.reduce((max, word) => Math.max(max, word.frequency), 1);
const getFrequencyColor = (freq: number) => {
const ratio = freq / maxFreq;
if (ratio > 0.5) return 'text-ctp-blue bg-ctp-blue/10';
if (ratio > 0.2) return 'text-ctp-green bg-ctp-green/10';
return 'text-ctp-mauve bg-ctp-mauve/10';
};
return (
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold text-ctp-text">
{titleBySort[sortBy]}
{search && (
<span className="ml-2 text-ctp-overlay1 font-normal">({filtered.length} matches)</span>
)}
</h3>
<select
value={sortBy}
onChange={(e) => {
setSortBy(e.target.value as SortKey);
setPage(0);
}}
className="text-xs bg-ctp-surface1 text-ctp-subtext0 border border-ctp-surface2 rounded px-2 py-1"
>
<option value="frequency">Frequency</option>
<option value="lastSeen">Last Seen</option>
<option value="firstSeen">First Seen</option>
</select>
</div>
<div className="flex flex-wrap gap-1.5">
{paged.map((w) => (
<button
type="button"
key={toWordKey(w)}
className={`inline-flex items-center gap-1 rounded px-2 py-0.5 text-xs transition ${getFrequencyColor(
w.frequency,
)} ${
selectedKey === toWordKey(w)
? 'ring-1 ring-ctp-blue ring-offset-1 ring-offset-ctp-surface0'
: 'hover:ring-1 hover:ring-ctp-surface2'
}`}
title={`${w.word} (${w.reading}) — seen ${w.frequency}x`}
onClick={() => onSelectWord?.(w)}
>
{w.headword}
{w.partOfSpeech && <PosBadge pos={w.partOfSpeech} />}
<span className="opacity-60">({w.frequency})</span>
</button>
))}
</div>
{totalPages > 1 && (
<div className="flex items-center justify-center gap-2 mt-3">
<button
type="button"
disabled={page === 0}
className="rounded border border-ctp-surface2 px-2 py-0.5 text-xs text-ctp-subtext0 transition hover:border-ctp-blue hover:text-ctp-blue disabled:opacity-40 disabled:cursor-not-allowed"
onClick={() => setPage((p) => p - 1)}
>
Prev
</button>
<span className="text-xs text-ctp-overlay1">
{page + 1} / {totalPages}
</span>
<button
type="button"
disabled={page >= totalPages - 1}
className="rounded border border-ctp-surface2 px-2 py-0.5 text-xs text-ctp-subtext0 transition hover:border-ctp-blue hover:text-ctp-blue disabled:opacity-40 disabled:cursor-not-allowed"
onClick={() => setPage((p) => p + 1)}
>
Next
</button>
</div>
)}
</div>
);
}
export { toWordKey };
@@ -0,0 +1,88 @@
import { readBooleanPreference, readStringPreference } from '../../lib/preference-storage';
import type {
StatsDictionaryWordPos,
StatsDictionaryWordQuery,
StatsDictionaryWordSort,
} from '../../types/stats';
export const WORD_VIEWS = ['worth', 'all', 'titles', 'newest'] as const;
export type WordView = (typeof WORD_VIEWS)[number];
export const WORD_PAGE_SIZE = 25;
// Beyond this rank a word is rarely worth prioritizing, and sorting by rank alone
// surfaces tokenizer artifacts, so Worth mining ranks by how often you meet a word.
const WORTH_MINING_MAX_RANK = 10_000;
const ACROSS_TITLES_MIN_TITLES = 2;
export const HIDE_KNOWN_KEY = 'subminer-stats-vocabulary-hide-known';
export const HIDE_KANA_KEY = 'subminer-stats-vocabulary-hide-kana';
export const HIDE_NAMES_KEY = 'subminer-stats-vocabulary-hide-names';
export const WORD_VIEW_KEY = 'subminer-stats-vocabulary-view';
export function loadWordView(): WordView {
const raw = readStringPreference(WORD_VIEW_KEY);
return WORD_VIEWS.find((view) => view === raw) ?? 'worth';
}
export function loadHideNames(): boolean {
return readBooleanPreference(HIDE_NAMES_KEY, true);
}
export interface WordSort {
sort: StatsDictionaryWordSort;
descending: boolean;
}
const VIEW_DEFAULT_SORT: Record<WordView, WordSort> = {
worth: { sort: 'seen', descending: true },
all: { sort: 'seen', descending: true },
titles: { sort: 'titles', descending: true },
newest: { sort: 'firstSeen', descending: true },
};
export function defaultSortForView(view: WordView): WordSort {
return VIEW_DEFAULT_SORT[view];
}
/** Clicking a sorted column flips it; a new column starts on its most useful end. */
export function nextSort(current: WordSort, column: StatsDictionaryWordSort): WordSort {
if (current.sort === column) return { sort: column, descending: !current.descending };
return { sort: column, descending: column !== 'rank' && column !== 'headword' };
}
export interface WordExplorerState {
view: WordView;
search: string;
hideKnown: boolean;
hideNames: boolean;
hideKana: boolean;
partOfSpeech: StatsDictionaryWordPos | null;
sort: WordSort;
page: number;
}
/**
* Turns the controls into a server query. Searching looks through everything, so
* typing a word you know or a name still finds it whatever the view and filters say.
*/
export function buildWordQuery(state: WordExplorerState): StatsDictionaryWordQuery {
const paging = {
...state.sort,
limit: WORD_PAGE_SIZE,
offset: state.page * WORD_PAGE_SIZE,
};
const search = state.search.trim();
if (search) {
return { ...paging, search, hideKnown: false, hideNames: false, hideKana: false };
}
const isWorth = state.view === 'worth';
return {
...paging,
hideKnown: isWorth || state.hideKnown,
hideNames: state.hideNames,
hideKana: state.hideKana,
partOfSpeech: state.partOfSpeech ?? undefined,
maxRank: isWorth ? WORTH_MINING_MAX_RANK : undefined,
minTitles: state.view === 'titles' ? ACROSS_TITLES_MIN_TITLES : undefined,
};
}
+37
View File
@@ -0,0 +1,37 @@
import { useEffect, useState } from 'react';
import { getStatsClient } from './useStatsApi';
import type { DictionaryWordPage, StatsDictionaryWordQuery } from '../types/stats';
/**
* Loads one page of the word list. The previous page stays in `page` while the
* next one loads, so the table doesn't collapse between filters.
*/
export function useDictionaryWords(query: StatsDictionaryWordQuery, refreshKey: number) {
const [page, setPage] = useState<DictionaryWordPage | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const queryKey = JSON.stringify(query);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
getStatsClient()
.getDictionaryWords(query)
.then((nextPage) => {
if (!cancelled) setPage(nextPage);
})
.catch((err: unknown) => {
if (!cancelled) setError(err instanceof Error ? err.message : String(err));
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
// `queryKey` stands in for `query`, which is a new object each render.
}, [queryKey, refreshKey]);
return { page, loading, error };
}
+14 -4
View File
@@ -1,13 +1,20 @@
import { useState, useEffect } from 'react';
import { getStatsClient } from './useStatsApi';
import { SESSION_CHART_EVENT_TYPES } from '../lib/session-events';
import type { SessionSummary, SessionTimelinePoint, SessionEvent } from '../types/stats';
import type {
SessionSummary,
SessionTimelinePoint,
SessionEvent,
StatsDayRange,
} from '../types/stats';
export function toErrorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
export function useSessions(limit = 50) {
export function useSessions(limit = 50, dayRange?: StatsDayRange | null) {
const startDay = dayRange?.startDay;
const endDay = dayRange?.endDay;
const [sessions, setSessions] = useState<SessionSummary[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -18,7 +25,10 @@ export function useSessions(limit = 50) {
setError(null);
const client = getStatsClient();
client
.getSessions(limit)
.getSessions(
limit,
startDay !== undefined && endDay !== undefined ? { startDay, endDay } : undefined,
)
.then((nextSessions) => {
if (cancelled) return;
setSessions(nextSessions);
@@ -34,7 +44,7 @@ export function useSessions(limit = 50) {
return () => {
cancelled = true;
};
}, [limit]);
}, [limit, startDay, endDay]);
return { sessions, loading, error };
}
+28 -10
View File
@@ -1,21 +1,38 @@
import { useState, useEffect } from 'react';
import { getStatsClient } from './useStatsApi';
import type { TrendsDashboardData } from '../types/stats';
import type {
StatsTrendGroupBy,
StatsTrendPresetRange,
StatsTrendRange,
TrendsDashboardData,
} from '../types/stats';
export type TimeRange = '7d' | '30d' | '90d' | '365d' | 'all';
export type GroupBy = 'day' | 'month';
export type TimeRange = StatsTrendRange;
export type PresetTimeRange = StatsTrendPresetRange;
export type GroupBy = StatsTrendGroupBy;
export function useTrends(range: TimeRange, groupBy: GroupBy, fillEmpty = true) {
/**
* Loads the dashboard for a selection. The previous payload stays in `data` while
* a new one loads, so controls and charts stay on screen; `refreshing` marks that
* state and `loading` is only true before the first payload arrives.
*/
export function useTrends(
range: TimeRange,
groupBy: GroupBy,
fillEmpty = true,
title: string | null = null,
) {
const [data, setData] = useState<TrendsDashboardData | null>(null);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(true);
const [error, setError] = useState<string | null>(null);
const rangeKey = typeof range === 'string' ? range : `${range.startDay}:${range.endDay}`;
useEffect(() => {
let cancelled = false;
setLoading(true);
setRefreshing(true);
setError(null);
getStatsClient()
.getTrendsDashboard(range, groupBy, fillEmpty)
.getTrendsDashboard(range, groupBy, fillEmpty, title)
.then((nextData) => {
if (cancelled) return;
setData(nextData);
@@ -26,12 +43,13 @@ export function useTrends(range: TimeRange, groupBy: GroupBy, fillEmpty = true)
})
.finally(() => {
if (cancelled) return;
setLoading(false);
setRefreshing(false);
});
return () => {
cancelled = true;
};
}, [range, groupBy, fillEmpty]);
// `rangeKey` stands in for `range`, which is a new object for each custom selection.
}, [rangeKey, groupBy, fillEmpty, title]);
return { data, loading, error };
return { data, loading: refreshing && data === null, refreshing, error };
}
+2 -4
View File
@@ -66,8 +66,6 @@ function summaryFixture(): StatsVocabularySummary {
function chartsFixture(overrides: Partial<StatsVocabularyCharts> = {}): StatsVocabularyCharts {
return {
ready: true,
topWords: [{ wordId: 1, headword: '猫', frequency: 5 }],
topWordsWithoutNames: [{ wordId: 1, headword: '猫', frequency: 5 }],
newWordsTimeline: [{ epochDay: 20_000, wordCount: 4 }],
newWordsTimelineWithoutNames: [{ epochDay: 20_000, wordCount: 4 }],
...overrides,
@@ -147,14 +145,14 @@ function stubVocabularyClient(overrides: {
getVocabularyCharts: () => Promise<StatsVocabularyCharts>;
}): () => void {
const original = {
getVocabulary: apiClient.getVocabulary,
getVocabularyCoverage: apiClient.getVocabularyCoverage,
getKanji: apiClient.getKanji,
getKnownWords: apiClient.getKnownWords,
getVocabularySummary: apiClient.getVocabularySummary,
getVocabularyCharts: apiClient.getVocabularyCharts,
setExcludedWords: apiClient.setExcludedWords,
};
apiClient.getVocabulary = async () => [];
apiClient.getVocabularyCoverage = async () => [];
apiClient.getKanji = async () => [];
apiClient.getKnownWords = async () => [];
apiClient.setExcludedWords = async () => {};
+18 -11
View File
@@ -2,10 +2,10 @@ import { useState, useEffect, useCallback } from 'react';
import { getStatsClient } from './useStatsApi';
import { subscribeExcludedWordsServerSync } from './useExcludedWords';
import type {
VocabularyEntry,
KanjiEntry,
StatsVocabularyCharts,
StatsVocabularySummary,
VocabularyCoverageBand,
} from '../types/stats';
const AGGREGATE_RETRY_BASE_MS = 1_000;
@@ -15,17 +15,19 @@ const CHART_BACKFILL_POLL_MS = 1_000;
const CHART_BACKFILL_SLOW_POLL_MS = 5_000;
const CHART_BACKFILL_FAST_POLLS = 30;
const CHART_BACKFILL_POLL_LIMIT = 60;
// Every kanji seen. The list stays small because the writing system does.
const KANJI_LIMIT = 10_000;
function aggregateRetryDelayMs(attempt: number): number {
return Math.min(AGGREGATE_RETRY_BASE_MS * 2 ** attempt, AGGREGATE_RETRY_MAX_MS);
}
export function useVocabulary() {
const [words, setWords] = useState<VocabularyEntry[]>([]);
const [kanji, setKanji] = useState<KanjiEntry[]>([]);
const [knownWords, setKnownWords] = useState<Set<string>>(new Set());
const [summary, setSummary] = useState<StatsVocabularySummary | null>(null);
const [charts, setCharts] = useState<StatsVocabularyCharts | null>(null);
const [coverage, setCoverage] = useState<VocabularyCoverageBand[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [aggregatesError, setAggregatesError] = useState<string | null>(null);
@@ -47,17 +49,11 @@ export function useVocabulary() {
setLoading(true);
setError(null);
const client = getStatsClient();
Promise.allSettled([client.getVocabulary(500), client.getKanji(200), client.getKnownWords()])
.then(([wordsResult, kanjiResult, knownResult]) => {
Promise.allSettled([client.getKanji(KANJI_LIMIT), client.getKnownWords()])
.then(([kanjiResult, knownResult]) => {
if (cancelled) return;
const errors: string[] = [];
if (wordsResult.status === 'fulfilled') {
setWords(wordsResult.value);
} else {
errors.push(wordsResult.reason.message);
}
if (kanjiResult.status === 'fulfilled') {
setKanji(kanjiResult.value);
} else {
@@ -145,6 +141,15 @@ export function useVocabulary() {
});
};
void client
.getVocabularyCoverage()
.then((nextCoverage) => {
if (!cancelled) setCoverage(nextCoverage);
})
.catch((coverageError: unknown) => {
// Coverage is a supplement, so losing it shouldn't flag the whole page.
console.error('Failed to load vocabulary coverage', coverageError);
});
loadSummary(0);
loadCharts(0, 0);
return () => {
@@ -154,11 +159,13 @@ export function useVocabulary() {
}, [aggregatesToken]);
return {
words,
kanji,
knownWords,
summary,
charts,
coverage,
/** Changes whenever server-side word data may have changed. */
dataRevision: aggregatesToken,
loading,
error,
aggregatesError,
+61
View File
@@ -89,6 +89,67 @@ test('deleteSession throws when the stats API delete request fails', async () =>
);
});
test('getTrendsDashboard encodes a custom range and title filter', async () => {
await withFetchStub(
() => jsonResponse({}),
async (requests) => {
await apiClient.getTrendsDashboard(
{ startDay: 20_000, endDay: 20_010 },
'week',
true,
'A & B',
);
assert.equal(
requests[0]?.url,
`${BASE_URL}/api/stats/trends/dashboard?range=custom&fromDay=20000&toDay=20010&groupBy=week&fillEmpty=true&title=A+%26+B`,
);
},
);
});
test('getDictionaryWords sends only the filters in use', async () => {
await withFetchStub(
() => jsonResponse({ rows: [], total: 0, maxSeen: 0 }),
async (requests) => {
await apiClient.getDictionaryWords({
search: '猫',
sort: 'rank',
descending: false,
hideKnown: true,
hideNames: false,
hideKana: false,
maxRank: 10_000,
limit: 25,
offset: 50,
});
const url = new URL(requests[0]!.url, 'http://stats.local');
assert.equal(url.pathname, '/api/stats/vocabulary/words');
assert.deepEqual(Object.fromEntries(url.searchParams), {
sort: 'rank',
order: 'asc',
limit: '25',
offset: '50',
search: '猫',
hideKnown: 'true',
maxRank: '10000',
});
},
);
});
test('getSessions narrows to a span of days when given one', async () => {
await withFetchStub(
() => jsonResponse([]),
async (requests) => {
await apiClient.getSessions(500, { startDay: 20_000, endDay: 20_006 });
assert.equal(
requests[0]?.url,
`${BASE_URL}/api/stats/sessions?limit=500&fromDay=20000&toDay=20006`,
);
},
);
});
test('getTrendsDashboard accepts 365d range and builds correct URL', async () => {
await withFetchStub(
() =>
+55 -8
View File
@@ -16,6 +16,8 @@ import type {
StatsMoveVideoRequest,
StatsMoveVideoResponse,
StatsTmdbAssignment,
StatsDayRange,
StatsDictionaryWordQuery,
StatsTrendGroupBy,
StatsTrendRange,
StatsVideoWatchedRequest,
@@ -67,7 +69,13 @@ export const apiClient = {
fetchJson('dailyRollups', `/api/stats/daily-rollups?limit=${limit}`),
getMonthlyRollups: (limit = 24) =>
fetchJson('monthlyRollups', `/api/stats/monthly-rollups?limit=${limit}`),
getSessions: (limit = 50) => fetchJson('sessions', `/api/stats/sessions?limit=${limit}`),
getSessions: (limit = 50, dayRange?: StatsDayRange) =>
fetchJson(
'sessions',
dayRange
? `/api/stats/sessions?limit=${limit}&fromDay=${dayRange.startDay}&toDay=${dayRange.endDay}`
: `/api/stats/sessions?limit=${limit}`,
),
getSessionTimeline: (id: number, limit?: number) =>
fetchJson(
'sessionTimeline',
@@ -87,6 +95,23 @@ export const apiClient = {
getVocabulary: (limit = 100) => fetchJson('vocabulary', `/api/stats/vocabulary?limit=${limit}`),
getVocabularySummary: () => fetchJson('vocabularySummary', '/api/stats/vocabulary/summary'),
getVocabularyCharts: () => fetchJson('vocabularyCharts', '/api/stats/vocabulary/charts'),
getDictionaryWords: (query: StatsDictionaryWordQuery) => {
const params = new URLSearchParams({
sort: query.sort,
order: query.descending ? 'desc' : 'asc',
limit: String(query.limit),
offset: String(query.offset),
});
if (query.search) params.set('search', query.search);
if (query.hideKnown) params.set('hideKnown', 'true');
if (query.hideNames) params.set('hideNames', 'true');
if (query.hideKana) params.set('hideKana', 'true');
if (query.partOfSpeech) params.set('pos', query.partOfSpeech);
if (query.maxRank !== undefined) params.set('maxRank', String(query.maxRank));
if (query.minTitles !== undefined) params.set('minTitles', String(query.minTitles));
return fetchJson('dictionaryWords', `/api/stats/vocabulary/words?${params.toString()}`);
},
getVocabularyCoverage: () => fetchJson('vocabularyCoverage', '/api/stats/vocabulary/coverage'),
getExcludedWords: () => fetchJson('excludedWords', '/api/stats/excluded-words'),
setExcludedWords: async (words: StatsExcludedWord[]): Promise<void> => {
await fetchResponse('/api/stats/excluded-words', {
@@ -108,10 +133,17 @@ export const apiClient = {
});
return res.json() as Promise<StatsDuplicateLineCleanupResult>;
},
getWordOccurrences: (headword: string, word: string, reading: string, limit = 50, offset = 0) =>
getWordOccurrences: (
headword: string,
word: string,
reading: string,
limit = 50,
offset = 0,
allForms = false,
) =>
fetchJson(
'wordOccurrences',
`/api/stats/vocabulary/occurrences?headword=${encodeURIComponent(headword)}&word=${encodeURIComponent(word)}&reading=${encodeURIComponent(reading)}&limit=${limit}&offset=${offset}`,
`/api/stats/vocabulary/occurrences?headword=${encodeURIComponent(headword)}&word=${encodeURIComponent(word)}&reading=${encodeURIComponent(reading)}&limit=${limit}&offset=${offset}${allForms ? '&allForms=true' : ''}`,
),
searchSentences: (query: string, limit = 50, searchByHeadword = true) =>
fetchJson(
@@ -159,11 +191,26 @@ export const apiClient = {
fetchJson('newAnimePerDay', `/api/stats/trends/new-anime-per-day?limit=${limit}`),
getWatchTimePerAnime: (limit = 90) =>
fetchJson('watchTimePerAnime', `/api/stats/trends/watch-time-per-anime?limit=${limit}`),
getTrendsDashboard: (range: StatsTrendRange, groupBy: StatsTrendGroupBy, fillEmpty = true) =>
fetchJson(
'trendsDashboard',
`/api/stats/trends/dashboard?range=${encodeURIComponent(range)}&groupBy=${encodeURIComponent(groupBy)}&fillEmpty=${fillEmpty ? 'true' : 'false'}`,
),
getTrendsDashboard: (
range: StatsTrendRange,
groupBy: StatsTrendGroupBy,
fillEmpty = true,
title: string | null = null,
) => {
const params = new URLSearchParams(
typeof range === 'string'
? { range }
: {
range: 'custom',
fromDay: String(range.startDay),
toDay: String(range.endDay),
},
);
params.set('groupBy', groupBy);
params.set('fillEmpty', fillEmpty ? 'true' : 'false');
if (title !== null) params.set('title', title);
return fetchJson('trendsDashboard', `/api/stats/trends/dashboard?${params.toString()}`);
},
getWordDetail: (wordId: number) =>
fetchJson('wordDetail', `/api/stats/vocabulary/${wordId}/detail`),
getKanjiDetail: (kanjiId: number) =>
+19
View File
@@ -5,6 +5,8 @@ import {
epochDayToDate,
epochMsFromDbTimestamp,
formatClockDuration,
isoDateToEpochDay,
localDayFromMs,
formatRelativeDate,
formatSessionDayLabel,
} from './formatters';
@@ -160,3 +162,20 @@ test('formatClockDuration keeps seconds for sub-hour spans', () => {
assert.equal(formatClockDuration(372_000), '6m 12s');
assert.equal(formatClockDuration(3_780_000), '1h 3m');
});
test('isoDateToEpochDay reads date input values and rejects dates before 1970', () => {
assert.equal(isoDateToEpochDay('1970-01-02'), 1);
assert.equal(isoDateToEpochDay('2026-03-01'), 20_513);
assert.equal(isoDateToEpochDay('1969-12-31'), null);
// Date.UTC would read this year as 1999.
assert.equal(isoDateToEpochDay('0099-01-01'), null);
assert.equal(isoDateToEpochDay(''), null);
});
test('localDayFromMs numbers the local calendar day in every time zone', () => {
const justAfterMidnight = new Date(2026, 0, 2, 0, 30).getTime();
const lateEvening = new Date(2026, 0, 2, 23, 30).getTime();
const january2 = Date.UTC(2026, 0, 2) / 86_400_000;
assert.equal(localDayFromMs(justAfterMidnight), january2);
assert.equal(localDayFromMs(lateEvening), january2);
});
+32 -2
View File
@@ -10,6 +10,21 @@ export function formatNumber(n: number): string {
return n.toLocaleString();
}
const COMPACT_NUMBER = new Intl.NumberFormat(undefined, {
notation: 'compact',
maximumFractionDigits: 1,
});
/** Short form for tight spaces like chart axes: 24000 becomes 24K. */
export function formatCompactNumber(n: number): string {
return COMPACT_NUMBER.format(n);
}
/** Watch time for headline numbers: minutes below an hour, then hours to one decimal. */
export function formatHours(minutes: number): string {
return minutes < 60 ? `${Math.round(minutes)}m` : `${(minutes / 60).toFixed(1)}h`;
}
export function formatPercent(ratio: number | null): string {
if (ratio == null) return '\u2014';
return `${Math.round(ratio * 100)}%`;
@@ -42,10 +57,25 @@ export function epochDayToDate(epochDay: number): Date {
return new Date(utcDate.getUTCFullYear(), utcDate.getUTCMonth(), utcDate.getUTCDate());
}
/** `YYYY-MM-DD` for a date input. */
export function epochDayToIsoDate(epochDay: number): string {
return new Date(epochDay * 86_400_000).toISOString().slice(0, 10);
}
/** Epoch day for a date input value, or null when the input is empty, invalid, or before 1970. */
export function isoDateToEpochDay(value: string): number | null {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
if (!match) return null;
const year = Number(match[1]);
if (year < 1970) return null;
const ms = Date.UTC(year, Number(match[2]) - 1, Number(match[3]));
return Number.isNaN(ms) ? null : ms / 86_400_000;
}
/** The local calendar day as an epoch day, the same numbering the stats server uses. */
export function localDayFromMs(ms: number): number {
const d = new Date(ms);
const localMidnight = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
return Math.floor(localMidnight / 86_400_000);
return Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()) / 86_400_000;
}
export function todayLocalDay(): number {
-6
View File
@@ -1,6 +0,0 @@
const KANA_ONLY_TEXT = /^[\p{Script=Hiragana}\p{Script=Katakana}\u30fc\u309d\u309e\u30fd\u30fe]+$/u;
export function isKanaOnlyTokenText(text: string): boolean {
const trimmed = text.trim();
return trimmed.length > 0 && KANA_ONLY_TEXT.test(trimmed);
}
+19
View File
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import {
buildStatsMineCardParams,
describeStreamedLines,
getStatsMineCardError,
getStatsMineCardUnavailableReason,
} from './mining';
@@ -67,6 +68,24 @@ test('buildStatsMineCardParams returns null when stored timing has no positive d
);
});
test('getStatsMineCardUnavailableReason says a line without a file came from a stream', () => {
assert.match(getStatsMineCardUnavailableReason(makeResult({ sourcePath: null })) ?? '', /stream/);
assert.equal(getStatsMineCardUnavailableReason(makeResult()), null);
});
for (const c of [
{ name: 'no streamed lines', lines: 10, streamed: 0, expected: null },
{ name: 'some streamed lines', lines: 2_863, streamed: 935, match: /935 of 2,863 lines/ },
{ name: 'only streamed lines', lines: 4, streamed: 4, match: /All 4 lines.*can't be mined yet/ },
{ name: 'a single streamed line', lines: 1, streamed: 1, match: /only line came from a stream/ },
]) {
test(`describeStreamedLines handles ${c.name}`, () => {
const text = describeStreamedLines(c.lines, c.streamed);
if (c.match) assert.match(text ?? '', c.match);
else assert.equal(text, null);
});
}
test('getStatsMineCardError surfaces partial media failures', () => {
assert.equal(
getStatsMineCardError({ noteId: 1, errors: ['audio: ffmpeg failed'] }),
+17 -1
View File
@@ -7,11 +7,27 @@ import type {
export type { StatsMineCardParams, StatsMineCardResponse, StatsMineMode } from '../types/stats';
// Cards cut their audio and image from the video file. Only streamed videos have no
// local path, so a missing path means the line came from a stream.
const STREAMED_LINE_REASON =
"Can't mine: from a stream, so there's no video file to cut clips from.";
/** Explains how a word's lines affect mining, or null when every line can be mined. */
export function describeStreamedLines(lineCount: number, streamLineCount: number): string | null {
if (streamLineCount === 0) return null;
if (streamLineCount >= lineCount) {
return lineCount === 1
? "This word's only line came from a stream, so it can't be mined yet."
: `All ${lineCount.toLocaleString()} lines with this word came from streams, so it can't be mined yet.`;
}
return `${streamLineCount.toLocaleString()} of ${lineCount.toLocaleString()} lines with this word came from streams and can't be mined.`;
}
export function getStatsMineCardUnavailableReason(
result: Pick<SentenceSearchResult, 'sourcePath' | 'segmentStartMs' | 'segmentEndMs'>,
): string | null {
if (!result.sourcePath) {
return 'This source has no local file path.';
return STREAMED_LINE_REASON;
}
if (result.segmentStartMs == null || result.segmentEndMs == null) {
return 'This line is missing segment timing.';
+16
View File
@@ -24,3 +24,19 @@ export function writeBooleanPreference(key: string, value: boolean): void {
// Storage can be blocked in private/restricted contexts; keep the in-memory choice.
}
}
export function readStringPreference(key: string): string | null {
try {
return getPreferenceStorage()?.getItem(key) ?? null;
} catch {
return null;
}
}
export function writeStringPreference(key: string, value: string): void {
try {
getPreferenceStorage()?.setItem(key, value);
} catch {
// Storage can be blocked in private/restricted contexts; keep the in-memory choice.
}
}
+1 -1
View File
@@ -61,7 +61,7 @@ test('getSentenceSearchMineAvailability disables every mining mode without sourc
canMineSentence: false,
canMineWordAudio: false,
exactMatch: true,
unavailableReason: 'This source has no local file path.',
unavailableReason: "Can't mine: from a stream, so there's no video file to cut clips from.",
});
});
+30
View File
@@ -5,8 +5,11 @@ import {
createInitialStatsView,
getSessionNavigationTarget,
navigateToAnime,
navigateToSessionsDays,
openAnimeEpisodeDetail,
openOverviewMediaDetail,
openSessionsMediaDetail,
openTrendsMediaDetail,
switchTab,
type StatsViewState,
} from './stats-navigation';
@@ -18,6 +21,7 @@ test('openAnimeEpisodeDetail opens dedicated media detail from anime context', (
activeTab: 'anime',
selectedAnimeId: 42,
focusedSessionId: null,
sessionsDayFilter: null,
mediaDetail: {
videoId: 7,
initialSessionId: null,
@@ -36,6 +40,7 @@ test('closeMediaDetail returns to originating anime detail state', () => {
activeTab: 'anime',
selectedAnimeId: 42,
focusedSessionId: null,
sessionsDayFilter: null,
mediaDetail: null,
} satisfies StatsViewState);
});
@@ -45,6 +50,7 @@ test('openOverviewMediaDetail opens dedicated media detail from overview context
activeTab: 'overview',
selectedAnimeId: null,
focusedSessionId: null,
sessionsDayFilter: null,
mediaDetail: {
videoId: 9,
initialSessionId: null,
@@ -68,6 +74,7 @@ test('switchTab clears dedicated media detail state', () => {
activeTab: 'sessions',
selectedAnimeId: null,
focusedSessionId: null,
sessionsDayFilter: null,
mediaDetail: null,
} satisfies StatsViewState);
});
@@ -92,6 +99,7 @@ test('openOverviewMediaDetail can carry a target session id for auto-expansion',
activeTab: 'overview',
selectedAnimeId: null,
focusedSessionId: null,
sessionsDayFilter: null,
mediaDetail: {
videoId: 9,
initialSessionId: 33,
@@ -101,3 +109,25 @@ test('openOverviewMediaDetail can carry a target session id for auto-expansion',
},
} satisfies StatsViewState);
});
const OCT_5 = { startDay: 20_366, endDay: 20_366, label: 'Oct 5' };
test('navigateToSessionsDays opens Sessions narrowed to those days', () => {
const state = navigateToSessionsDays(createInitialStatsView(), OCT_5);
assert.equal(state.activeTab, 'sessions');
assert.deepEqual(state.sessionsDayFilter, OCT_5);
});
test('the sessions day filter survives a media detail round trip but not a tab switch', () => {
const filtered = navigateToSessionsDays(createInitialStatsView(), OCT_5);
const back = closeMediaDetail(openSessionsMediaDetail(filtered, 3));
assert.equal(back.activeTab, 'sessions');
assert.deepEqual(back.sessionsDayFilter, OCT_5);
assert.equal(switchTab(filtered, 'sessions').sessionsDayFilter, null);
});
test('media detail opened from Trends returns to Trends', () => {
const state = closeMediaDetail(openTrendsMediaDetail(createInitialStatsView(), 3));
assert.equal(state.activeTab, 'trends');
assert.equal(state.mediaDetail, null);
});
+52 -16
View File
@@ -1,10 +1,11 @@
import type { SessionSummary } from '../types/stats';
import type { SessionSummary, StatsDayRange } from '../types/stats';
import type { TabId } from '../components/layout/TabBar';
export type MediaDetailOrigin =
| { type: 'anime'; animeId: number }
| { type: 'overview' }
| { type: 'sessions' };
| { type: 'sessions' }
| { type: 'trends' };
export interface MediaDetailState {
videoId: number;
@@ -12,10 +13,16 @@ export interface MediaDetailState {
origin: MediaDetailOrigin;
}
/** Narrows the Sessions tab to a span of days, set when arriving from a Trends chart. */
export interface SessionsDayFilter extends StatsDayRange {
label: string;
}
export interface StatsViewState {
activeTab: TabId;
selectedAnimeId: number | null;
focusedSessionId: number | null;
sessionsDayFilter: SessionsDayFilter | null;
mediaDetail: MediaDetailState | null;
}
@@ -24,6 +31,7 @@ export function createInitialStatsView(): StatsViewState {
activeTab: 'overview',
selectedAnimeId: null,
focusedSessionId: null,
sessionsDayFilter: null,
mediaDetail: null,
};
}
@@ -33,6 +41,7 @@ export function switchTab(state: StatsViewState, tabId: TabId): StatsViewState {
activeTab: tabId,
selectedAnimeId: null,
focusedSessionId: tabId === 'sessions' ? state.focusedSessionId : null,
sessionsDayFilter: null,
mediaDetail: null,
};
}
@@ -51,6 +60,20 @@ export function navigateToSession(state: StatsViewState, sessionId: number): Sta
...state,
activeTab: 'sessions',
focusedSessionId: sessionId,
sessionsDayFilter: null,
mediaDetail: null,
};
}
export function navigateToSessionsDays(
state: StatsViewState,
sessionsDayFilter: SessionsDayFilter,
): StatsViewState {
return {
...state,
activeTab: 'sessions',
focusedSessionId: null,
sessionsDayFilter,
mediaDetail: null,
};
}
@@ -65,6 +88,7 @@ export function openAnimeEpisodeDetail(
activeTab: 'anime',
selectedAnimeId: animeId,
focusedSessionId: null,
sessionsDayFilter: null,
mediaDetail: {
videoId,
initialSessionId: sessionId,
@@ -85,6 +109,7 @@ export function openOverviewMediaDetail(
activeTab: 'overview',
selectedAnimeId: null,
focusedSessionId: null,
sessionsDayFilter: null,
mediaDetail: {
videoId,
initialSessionId: sessionId,
@@ -100,6 +125,7 @@ export function openSessionsMediaDetail(state: StatsViewState, videoId: number):
activeTab: 'sessions',
selectedAnimeId: null,
focusedSessionId: null,
sessionsDayFilter: state.sessionsDayFilter,
mediaDetail: {
videoId,
initialSessionId: null,
@@ -110,33 +136,43 @@ export function openSessionsMediaDetail(state: StatsViewState, videoId: number):
};
}
export function openTrendsMediaDetail(_state: StatsViewState, videoId: number): StatsViewState {
return {
activeTab: 'trends',
selectedAnimeId: null,
focusedSessionId: null,
sessionsDayFilter: null,
mediaDetail: {
videoId,
initialSessionId: null,
origin: {
type: 'trends',
},
},
};
}
export function closeMediaDetail(state: StatsViewState): StatsViewState {
if (!state.mediaDetail) {
return state;
}
if (state.mediaDetail.origin.type === 'overview') {
const { origin } = state.mediaDetail;
if (origin.type === 'anime') {
return {
activeTab: 'overview',
selectedAnimeId: null,
focusedSessionId: null,
mediaDetail: null,
};
}
if (state.mediaDetail.origin.type === 'sessions') {
return {
activeTab: 'sessions',
selectedAnimeId: null,
activeTab: 'anime',
selectedAnimeId: origin.animeId,
focusedSessionId: null,
sessionsDayFilter: null,
mediaDetail: null,
};
}
return {
activeTab: 'anime',
selectedAnimeId: state.mediaDetail.origin.animeId,
activeTab: origin.type,
selectedAnimeId: null,
focusedSessionId: null,
sessionsDayFilter: origin.type === 'sessions' ? state.sessionsDayFilter : null,
mediaDetail: null,
};
}
+2 -1
View File
@@ -30,7 +30,8 @@
--color-ctp-pink: #f5bde6;
--font-sans:
'Geist Variable', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
'Geist Variable', 'Noto Sans CJK JP', 'Noto Sans JP', 'Hiragino Sans', 'Yu Gothic UI',
-apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
--font-mono: 'Geist Mono Variable', 'JetBrains Mono', 'Fira Code', ui-monospace, monospace;
}