feat(stats): add v1 immersion stats dashboard (#19)

This commit is contained in:
2026-03-20 02:43:28 -07:00
committed by GitHub
parent 42abdd1268
commit 6749ff843c
555 changed files with 46356 additions and 2553 deletions
@@ -0,0 +1,45 @@
import { StatCard } from '../layout/StatCard';
import { formatDuration, formatNumber, todayLocalDay, localDayFromMs } from '../../lib/formatters';
import type { OverviewSummary } from '../../lib/dashboard-data';
import type { SessionSummary } from '../../types/stats';
interface HeroStatsProps {
summary: OverviewSummary;
sessions: SessionSummary[];
}
export function HeroStats({ summary, sessions }: HeroStatsProps) {
const today = todayLocalDay();
const sessionsToday = sessions.filter((s) => localDayFromMs(s.startedAtMs) === today).length;
return (
<div className="grid grid-cols-2 xl:grid-cols-6 gap-3">
<StatCard
label="Watch Time Today"
value={formatDuration(summary.todayActiveMs)}
color="text-ctp-blue"
/>
<StatCard
label="Cards Mined Today"
value={formatNumber(summary.todayCards)}
color="text-ctp-cards-mined"
/>
<StatCard
label="Sessions Today"
value={formatNumber(sessionsToday)}
color="text-ctp-lavender"
/>
<StatCard
label="Episodes Today"
value={formatNumber(summary.episodesToday)}
color="text-ctp-teal"
/>
<StatCard label="Current Streak" value={`${summary.streakDays}d`} color="text-ctp-peach" />
<StatCard
label="Active Anime"
value={formatNumber(summary.activeAnimeCount)}
color="text-ctp-mauve"
/>
</div>
);
}
@@ -0,0 +1,158 @@
import { useState, useEffect } from 'react';
import { useOverview } from '../../hooks/useOverview';
import { useStreakCalendar } from '../../hooks/useStreakCalendar';
import { HeroStats } from './HeroStats';
import { StreakCalendar } from './StreakCalendar';
import { RecentSessions } from './RecentSessions';
import { TrackingSnapshot } from './TrackingSnapshot';
import { TrendChart } from '../trends/TrendChart';
import { buildOverviewSummary, buildStreakCalendar } from '../../lib/dashboard-data';
import { apiClient } from '../../lib/api-client';
import { getStatsClient } from '../../hooks/useStatsApi';
import {
confirmSessionDelete,
confirmDayGroupDelete,
confirmAnimeGroupDelete,
} from '../../lib/delete-confirm';
import type { SessionSummary } from '../../types/stats';
interface OverviewTabProps {
onNavigateToMediaDetail: (videoId: number, sessionId?: number | null) => void;
onNavigateToSession: (sessionId: number) => void;
}
export function OverviewTab({ onNavigateToMediaDetail, onNavigateToSession }: OverviewTabProps) {
const { data, sessions, setSessions, loading, error } = useOverview();
const { calendar, loading: calLoading } = useStreakCalendar(90);
const [deleteError, setDeleteError] = useState<string | null>(null);
const [deletingIds, setDeletingIds] = useState<Set<number>>(new Set());
const [knownWordsSummary, setKnownWordsSummary] = useState<{
totalUniqueWords: number;
knownWordCount: number;
} | null>(null);
useEffect(() => {
let cancelled = false;
getStatsClient()
.getKnownWordsSummary()
.then((data) => {
if (!cancelled) setKnownWordsSummary(data);
})
.catch(() => {
if (!cancelled) setKnownWordsSummary(null);
});
return () => {
cancelled = true;
};
}, []);
const handleDeleteSession = async (session: SessionSummary) => {
if (!confirmSessionDelete()) return;
setDeleteError(null);
setDeletingIds((prev) => new Set(prev).add(session.sessionId));
try {
await apiClient.deleteSession(session.sessionId);
setSessions((prev) => prev.filter((s) => s.sessionId !== session.sessionId));
} catch (err) {
setDeleteError(err instanceof Error ? err.message : 'Failed to delete session.');
} finally {
setDeletingIds((prev) => {
const next = new Set(prev);
next.delete(session.sessionId);
return next;
});
}
};
const handleDeleteDayGroup = async (dayLabel: string, daySessions: SessionSummary[]) => {
if (!confirmDayGroupDelete(dayLabel, daySessions.length)) return;
setDeleteError(null);
const ids = daySessions.map((s) => s.sessionId);
setDeletingIds((prev) => {
const next = new Set(prev);
for (const id of ids) next.add(id);
return next;
});
try {
await apiClient.deleteSessions(ids);
const idSet = new Set(ids);
setSessions((prev) => prev.filter((s) => !idSet.has(s.sessionId)));
} catch (err) {
setDeleteError(err instanceof Error ? err.message : 'Failed to delete sessions.');
} finally {
setDeletingIds((prev) => {
const next = new Set(prev);
for (const id of ids) next.delete(id);
return next;
});
}
};
const handleDeleteAnimeGroup = async (groupSessions: SessionSummary[]) => {
const title =
groupSessions[0]?.animeTitle ?? groupSessions[0]?.canonicalTitle ?? 'Unknown Media';
if (!confirmAnimeGroupDelete(title, groupSessions.length)) return;
setDeleteError(null);
const ids = groupSessions.map((s) => s.sessionId);
setDeletingIds((prev) => {
const next = new Set(prev);
for (const id of ids) next.add(id);
return next;
});
try {
await apiClient.deleteSessions(ids);
const idSet = new Set(ids);
setSessions((prev) => prev.filter((s) => !idSet.has(s.sessionId)));
} catch (err) {
setDeleteError(err instanceof Error ? err.message : 'Failed to delete sessions.');
} finally {
setDeletingIds((prev) => {
const next = new Set(prev);
for (const id of ids) next.delete(id);
return next;
});
}
};
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) return null;
const summary = buildOverviewSummary(data);
const streakData = buildStreakCalendar(calendar);
const showTrackedCardNote = summary.totalTrackedCards === 0 && summary.activeDays > 0;
return (
<div className="space-y-4">
<HeroStats summary={summary} sessions={sessions} />
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<TrendChart
title="Last 14 Days Watch Time (min)"
data={summary.recentWatchTime}
color="#8aadf4"
type="bar"
/>
{!calLoading && <StreakCalendar data={streakData} />}
</div>
<TrackingSnapshot
summary={summary}
showTrackedCardNote={showTrackedCardNote}
knownWordsSummary={knownWordsSummary}
/>
{deleteError ? <div className="text-sm text-ctp-red">{deleteError}</div> : null}
<RecentSessions
sessions={sessions}
onNavigateToMediaDetail={onNavigateToMediaDetail}
onNavigateToSession={onNavigateToSession}
onDeleteSession={handleDeleteSession}
onDeleteDayGroup={handleDeleteDayGroup}
onDeleteAnimeGroup={handleDeleteAnimeGroup}
deletingIds={deletingIds}
/>
</div>
);
}
@@ -0,0 +1,46 @@
import { todayLocalDay } from '../../lib/formatters';
import type { DailyRollup } from '../../types/stats';
interface QuickStatsProps {
rollups: DailyRollup[];
}
export function QuickStats({ rollups }: QuickStatsProps) {
const daysWithActivity = new Set(
rollups.filter((r) => r.totalActiveMin > 0).map((r) => r.rollupDayOrMonth),
);
const today = todayLocalDay();
const streakStart = daysWithActivity.has(today) ? today : today - 1;
let streak = 0;
for (let d = streakStart; daysWithActivity.has(d); d--) {
streak++;
}
const weekStart = today - 6;
const weekRollups = rollups.filter((r) => r.rollupDayOrMonth >= weekStart);
const weekMinutes = weekRollups.reduce((sum, r) => sum + r.totalActiveMin, 0);
const weekCards = weekRollups.reduce((sum, r) => sum + r.totalCards, 0);
const avgMinPerDay = Math.round(weekMinutes / 7);
return (
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
<h3 className="text-sm font-semibold text-ctp-text mb-3">Quick Stats</h3>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-ctp-subtext0">Streak</span>
<span className="text-ctp-peach font-medium">
{streak} day{streak !== 1 ? 's' : ''}
</span>
</div>
<div className="flex justify-between">
<span className="text-ctp-subtext0">Avg/day this week</span>
<span className="text-ctp-text">{avgMinPerDay}m</span>
</div>
<div className="flex justify-between">
<span className="text-ctp-subtext0">Cards this week</span>
<span className="text-ctp-cards-mined font-medium">{weekCards}</span>
</div>
</div>
</div>
);
}
@@ -0,0 +1,433 @@
import { useState } from 'react';
import {
formatDuration,
formatRelativeDate,
formatNumber,
formatSessionDayLabel,
} from '../../lib/formatters';
import { BASE_URL } from '../../lib/api-client';
import { getSessionDisplayWordCount } from '../../lib/session-word-count';
import { getSessionNavigationTarget } from '../../lib/stats-navigation';
import type { SessionSummary } from '../../types/stats';
interface RecentSessionsProps {
sessions: SessionSummary[];
onNavigateToMediaDetail: (videoId: number, sessionId?: number | null) => void;
onNavigateToSession: (sessionId: number) => void;
onDeleteSession: (session: SessionSummary) => void;
onDeleteDayGroup: (dayLabel: string, daySessions: SessionSummary[]) => void;
onDeleteAnimeGroup: (sessions: SessionSummary[]) => void;
deletingIds: Set<number>;
}
interface AnimeGroup {
key: string;
animeId: number | null;
animeTitle: string | null;
videoId: number | null;
sessions: SessionSummary[];
totalCards: number;
totalWords: number;
totalActiveMs: number;
totalKnownWords: number;
}
function groupSessionsByDay(sessions: SessionSummary[]): Map<string, SessionSummary[]> {
const groups = new Map<string, SessionSummary[]>();
for (const session of sessions) {
const dayLabel = formatSessionDayLabel(session.startedAtMs);
const group = groups.get(dayLabel);
if (group) {
group.push(session);
} else {
groups.set(dayLabel, [session]);
}
}
return groups;
}
function groupSessionsByAnime(sessions: SessionSummary[]): AnimeGroup[] {
const map = new Map<string, AnimeGroup>();
for (const session of sessions) {
const key =
session.animeId != null
? `anime-${session.animeId}`
: session.videoId != null
? `video-${session.videoId}`
: `session-${session.sessionId}`;
const existing = map.get(key);
const displayWordCount = getSessionDisplayWordCount(session);
if (existing) {
existing.sessions.push(session);
existing.totalCards += session.cardsMined;
existing.totalWords += displayWordCount;
existing.totalActiveMs += session.activeWatchedMs;
existing.totalKnownWords += session.knownWordsSeen;
} else {
map.set(key, {
key,
animeId: session.animeId,
animeTitle: session.animeTitle,
videoId: session.videoId,
sessions: [session],
totalCards: session.cardsMined,
totalWords: displayWordCount,
totalActiveMs: session.activeWatchedMs,
totalKnownWords: session.knownWordsSeen,
});
}
}
return Array.from(map.values());
}
function CoverThumbnail({
animeId,
videoId,
title,
}: {
animeId: number | null;
videoId: number | null;
title: string;
}) {
const fallbackChar = title.charAt(0) || '?';
const [isFallback, setIsFallback] = useState(false);
if ((!animeId && !videoId) || isFallback) {
return (
<div className="w-12 h-16 rounded bg-ctp-surface2 flex items-center justify-center text-ctp-overlay2 text-lg font-bold shrink-0">
{fallbackChar}
</div>
);
}
const src =
animeId != null
? `${BASE_URL}/api/stats/anime/${animeId}/cover`
: `${BASE_URL}/api/stats/media/${videoId}/cover`;
return (
<img
src={src}
alt=""
className="w-12 h-16 rounded object-cover shrink-0 bg-ctp-surface2"
onError={() => setIsFallback(true)}
/>
);
}
function SessionItem({
session,
onNavigateToMediaDetail,
onNavigateToSession,
onDelete,
deleteDisabled,
}: {
session: SessionSummary;
onNavigateToMediaDetail: (videoId: number, sessionId?: number | null) => void;
onNavigateToSession: (sessionId: number) => void;
onDelete: () => void;
deleteDisabled: boolean;
}) {
const displayWordCount = getSessionDisplayWordCount(session);
const navigationTarget = getSessionNavigationTarget(session);
return (
<div className="relative group">
<button
type="button"
onClick={() => {
if (navigationTarget.type === 'media-detail') {
onNavigateToMediaDetail(navigationTarget.videoId, navigationTarget.sessionId);
return;
}
onNavigateToSession(navigationTarget.sessionId);
}}
className="w-full bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-3 pr-10 flex items-center gap-3 hover:border-ctp-surface2 transition-colors text-left cursor-pointer"
>
<CoverThumbnail
animeId={session.animeId}
videoId={session.videoId}
title={session.canonicalTitle ?? 'Unknown'}
/>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-ctp-text truncate">
{session.canonicalTitle ?? 'Unknown Media'}
</div>
<div className="text-xs text-ctp-overlay2">
{formatRelativeDate(session.startedAtMs)} · {formatDuration(session.activeWatchedMs)}{' '}
active
</div>
</div>
<div className="flex gap-4 text-xs text-center shrink-0">
<div>
<div className="text-ctp-cards-mined font-medium font-mono tabular-nums">
{formatNumber(session.cardsMined)}
</div>
<div className="text-ctp-overlay2">cards</div>
</div>
<div>
<div className="text-ctp-mauve font-medium font-mono tabular-nums">
{formatNumber(displayWordCount)}
</div>
<div className="text-ctp-overlay2">words</div>
</div>
<div>
<div className="text-ctp-green font-medium font-mono tabular-nums">
{formatNumber(session.knownWordsSeen)}
</div>
<div className="text-ctp-overlay2">known words</div>
</div>
</div>
</button>
<button
type="button"
onClick={onDelete}
disabled={deleteDisabled}
aria-label={`Delete session ${session.canonicalTitle ?? 'Unknown Media'}`}
className="absolute right-2 top-1/2 -translate-y-1/2 w-5 h-5 rounded border border-ctp-surface2 text-transparent hover:border-ctp-red/50 hover:text-ctp-red hover:bg-ctp-red/10 transition-colors opacity-0 group-hover:opacity-100 focus:opacity-100 flex items-center justify-center disabled:opacity-40 disabled:cursor-not-allowed"
title="Delete session"
>
{'\u2715'}
</button>
</div>
);
}
function AnimeGroupRow({
group,
onNavigateToMediaDetail,
onNavigateToSession,
onDeleteSession,
onDeleteAnimeGroup,
deletingIds,
}: {
group: AnimeGroup;
onNavigateToMediaDetail: (videoId: number, sessionId?: number | null) => void;
onNavigateToSession: (sessionId: number) => void;
onDeleteSession: (session: SessionSummary) => void;
onDeleteAnimeGroup: (group: AnimeGroup) => void;
deletingIds: Set<number>;
}) {
const [expanded, setExpanded] = useState(false);
const groupDeleting = group.sessions.some((s) => deletingIds.has(s.sessionId));
if (group.sessions.length === 1) {
const s = group.sessions[0]!;
return (
<SessionItem
session={s}
onNavigateToMediaDetail={onNavigateToMediaDetail}
onNavigateToSession={onNavigateToSession}
onDelete={() => onDeleteSession(s)}
deleteDisabled={deletingIds.has(s.sessionId)}
/>
);
}
const displayTitle = group.animeTitle ?? group.sessions[0]?.canonicalTitle ?? 'Unknown Media';
const mostRecentSession = group.sessions[0]!;
const disclosureId = `recent-sessions-${mostRecentSession.sessionId}`;
return (
<div className="group/anime">
<div className="relative">
<button
type="button"
onClick={() => setExpanded((value) => !value)}
aria-expanded={expanded}
aria-controls={disclosureId}
className="w-full bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-3 pr-10 flex items-center gap-3 hover:border-ctp-surface2 transition-colors text-left"
>
<CoverThumbnail
animeId={group.animeId}
videoId={mostRecentSession.videoId}
title={displayTitle}
/>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-ctp-text truncate">{displayTitle}</div>
<div className="text-xs text-ctp-overlay2">
{group.sessions.length} sessions · {formatDuration(group.totalActiveMs)} active
</div>
</div>
<div className="flex gap-4 text-xs text-center shrink-0">
<div>
<div className="text-ctp-cards-mined font-medium font-mono tabular-nums">
{formatNumber(group.totalCards)}
</div>
<div className="text-ctp-overlay2">cards</div>
</div>
<div>
<div className="text-ctp-mauve font-medium font-mono tabular-nums">
{formatNumber(group.totalWords)}
</div>
<div className="text-ctp-overlay2">words</div>
</div>
<div>
<div className="text-ctp-green font-medium font-mono tabular-nums">
{formatNumber(group.totalKnownWords)}
</div>
<div className="text-ctp-overlay2">known words</div>
</div>
</div>
<div
className={`text-ctp-overlay2 text-xs transition-transform ${expanded ? 'rotate-90' : ''}`}
aria-hidden="true"
>
{'\u25B8'}
</div>
</button>
<button
type="button"
onClick={() => onDeleteAnimeGroup(group)}
disabled={groupDeleting}
aria-label={`Delete all sessions for ${displayTitle}`}
className="absolute right-2 top-1/2 -translate-y-1/2 w-5 h-5 rounded border border-ctp-surface2 text-transparent hover:border-ctp-red/50 hover:text-ctp-red hover:bg-ctp-red/10 transition-colors opacity-0 group-hover/anime:opacity-100 focus:opacity-100 flex items-center justify-center disabled:opacity-40 disabled:cursor-not-allowed"
title={`Delete all sessions for ${displayTitle}`}
>
{groupDeleting ? '\u2026' : '\u2715'}
</button>
</div>
{expanded && (
<div
id={disclosureId}
role="region"
aria-label={`${displayTitle} sessions`}
className="ml-6 mt-1 space-y-1"
>
{group.sessions.map((s) => {
const navigationTarget = getSessionNavigationTarget(s);
return (
<div key={s.sessionId} className="relative group/nested">
<button
type="button"
onClick={() => {
if (navigationTarget.type === 'media-detail') {
onNavigateToMediaDetail(navigationTarget.videoId, navigationTarget.sessionId);
return;
}
onNavigateToSession(navigationTarget.sessionId);
}}
className="w-full bg-ctp-mantle border border-ctp-surface0 rounded-lg p-2.5 pr-10 flex items-center gap-3 hover:border-ctp-surface1 transition-colors text-left cursor-pointer"
>
<CoverThumbnail
animeId={s.animeId}
videoId={s.videoId}
title={s.canonicalTitle ?? 'Unknown'}
/>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-ctp-subtext1 truncate">
{s.canonicalTitle ?? 'Unknown Media'}
</div>
<div className="text-xs text-ctp-overlay2">
{formatRelativeDate(s.startedAtMs)} · {formatDuration(s.activeWatchedMs)}{' '}
active
</div>
</div>
<div className="flex gap-4 text-xs text-center shrink-0">
<div>
<div className="text-ctp-cards-mined font-medium font-mono tabular-nums">
{formatNumber(s.cardsMined)}
</div>
<div className="text-ctp-overlay2">cards</div>
</div>
<div>
<div className="text-ctp-mauve font-medium font-mono tabular-nums">
{formatNumber(getSessionDisplayWordCount(s))}
</div>
<div className="text-ctp-overlay2">words</div>
</div>
<div>
<div className="text-ctp-green font-medium font-mono tabular-nums">
{formatNumber(s.knownWordsSeen)}
</div>
<div className="text-ctp-overlay2">known words</div>
</div>
</div>
</button>
<button
type="button"
onClick={() => onDeleteSession(s)}
disabled={deletingIds.has(s.sessionId)}
aria-label={`Delete session ${s.canonicalTitle ?? 'Unknown Media'}`}
className="absolute right-2 top-1/2 -translate-y-1/2 w-5 h-5 rounded border border-ctp-surface2 text-transparent hover:border-ctp-red/50 hover:text-ctp-red hover:bg-ctp-red/10 transition-colors opacity-0 group-hover/nested:opacity-100 focus:opacity-100 flex items-center justify-center disabled:opacity-40 disabled:cursor-not-allowed"
title="Delete session"
>
{'\u2715'}
</button>
</div>
);
})}
</div>
)}
</div>
);
}
export function RecentSessions({
sessions,
onNavigateToMediaDetail,
onNavigateToSession,
onDeleteSession,
onDeleteDayGroup,
onDeleteAnimeGroup,
deletingIds,
}: RecentSessionsProps) {
if (sessions.length === 0) {
return (
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
<div className="text-sm text-ctp-overlay2">No sessions yet</div>
</div>
);
}
const groups = groupSessionsByDay(sessions);
const anyDeleting = deletingIds.size > 0;
return (
<div className="space-y-4">
{Array.from(groups.entries()).map(([dayLabel, daySessions]) => {
const animeGroups = groupSessionsByAnime(daySessions);
const groupDeleting = daySessions.some((s) => deletingIds.has(s.sessionId));
return (
<div key={dayLabel} className="group/day">
<div className="flex items-center gap-3 mb-2">
<h3 className="text-xs font-semibold text-ctp-overlay2 uppercase tracking-widest shrink-0">
{dayLabel}
</h3>
<div className="flex-1 h-px bg-gradient-to-r from-ctp-surface1 to-transparent" />
<button
type="button"
onClick={() => onDeleteDayGroup(dayLabel, daySessions)}
disabled={anyDeleting}
aria-label={`Delete all sessions from ${dayLabel}`}
className="shrink-0 text-xs text-transparent hover:text-ctp-red transition-colors opacity-0 group-hover/day:opacity-100 focus:opacity-100 disabled:opacity-40 disabled:cursor-not-allowed"
title={`Delete all sessions from ${dayLabel}`}
>
{groupDeleting ? '\u2026' : '\u2715'}
</button>
</div>
<div className="space-y-2">
{animeGroups.map((group) => (
<AnimeGroupRow
key={group.key}
group={group}
onNavigateToMediaDetail={onNavigateToMediaDetail}
onNavigateToSession={onNavigateToSession}
onDeleteSession={onDeleteSession}
onDeleteAnimeGroup={(g) => onDeleteAnimeGroup(g.sessions)}
deletingIds={deletingIds}
/>
))}
</div>
</div>
);
})}
</div>
);
}
@@ -0,0 +1,96 @@
import { useState } from 'react';
import type { StreakCalendarPoint } from '../../lib/dashboard-data';
interface StreakCalendarProps {
data: StreakCalendarPoint[];
}
function intensityClass(value: number): string {
if (value === 0) return 'bg-ctp-surface0';
if (value <= 30) return 'bg-ctp-green/30';
if (value <= 60) return 'bg-ctp-green/60';
return 'bg-ctp-green';
}
const DAY_LABELS = ['Mon', '', 'Wed', '', 'Fri', '', ''];
export function StreakCalendar({ data }: StreakCalendarProps) {
const [tooltip, setTooltip] = useState<{ x: number; y: number; text: string } | null>(null);
const lookup = new Map(data.map((d) => [d.date, d.value]));
const today = new Date();
today.setHours(0, 0, 0, 0);
const endDate = new Date(today);
const startDate = new Date(today);
startDate.setDate(startDate.getDate() - 89);
const startDow = (startDate.getDay() + 6) % 7;
const cells: Array<{ date: string; value: number; row: number; col: number }> = [];
let col = 0;
let row = startDow;
const cursor = new Date(startDate);
while (cursor <= endDate) {
const dateStr = `${cursor.getFullYear()}-${String(cursor.getMonth() + 1).padStart(2, '0')}-${String(cursor.getDate()).padStart(2, '0')}`;
cells.push({ date: dateStr, value: lookup.get(dateStr) ?? 0, row, col });
row += 1;
if (row >= 7) {
row = 0;
col += 1;
}
cursor.setDate(cursor.getDate() + 1);
}
const totalCols = col + (row > 0 ? 1 : 0);
return (
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
<h3 className="text-sm font-semibold text-ctp-text mb-3">Activity (90 days)</h3>
<div className="relative flex gap-1">
<div className="flex flex-col gap-1 text-[10px] text-ctp-overlay2 pr-1 shrink-0">
{DAY_LABELS.map((label, i) => (
<div key={i} className="h-3 flex items-center leading-none">
{label}
</div>
))}
</div>
<div
className="grid gap-[3px]"
style={{
gridTemplateColumns: `repeat(${totalCols}, 12px)`,
gridTemplateRows: 'repeat(7, 12px)',
}}
>
{cells.map((cell) => (
<div
key={cell.date}
className={`w-3 h-3 rounded-sm ${intensityClass(cell.value)} cursor-default`}
style={{ gridRow: cell.row + 1, gridColumn: cell.col + 1 }}
onMouseEnter={(e) => {
const rect = e.currentTarget.getBoundingClientRect();
setTooltip({
x: rect.left + rect.width / 2,
y: rect.top - 4,
text: `${cell.date}: ${Math.round(cell.value * 100) / 100}m`,
});
}}
onMouseLeave={() => setTooltip(null)}
/>
))}
</div>
{tooltip && (
<div
className="fixed z-50 px-2 py-1 text-xs bg-ctp-crust text-ctp-text rounded shadow-lg pointer-events-none -translate-x-1/2 -translate-y-full"
style={{ left: tooltip.x, top: tooltip.y }}
>
{tooltip.text}
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,47 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { renderToStaticMarkup } from 'react-dom/server';
import { TrackingSnapshot } from './TrackingSnapshot';
import type { OverviewSummary } from '../../lib/dashboard-data';
const summary: OverviewSummary = {
todayActiveMs: 0,
todayCards: 0,
streakDays: 0,
allTimeMinutes: 120,
totalTrackedCards: 9,
episodesToday: 0,
activeAnimeCount: 0,
totalEpisodesWatched: 5,
totalAnimeCompleted: 1,
averageSessionMinutes: 40,
activeDays: 12,
totalSessions: 15,
lookupRate: {
shortValue: '2.3 / 100 words',
longValue: '2.3 lookups per 100 words',
},
todayTokens: 0,
newWordsToday: 0,
newWordsThisWeek: 0,
recentWatchTime: [],
};
test('TrackingSnapshot renders Yomitan lookup rate copy on the homepage card', () => {
const markup = renderToStaticMarkup(
<TrackingSnapshot summary={summary} knownWordsSummary={null} />,
);
assert.match(markup, /Lookup Rate/);
assert.match(markup, /2\.3 \/ 100 words/);
assert.match(markup, /Lifetime Yomitan lookups normalized by total words seen/);
});
test('TrackingSnapshot labels new words as unique headwords', () => {
const markup = renderToStaticMarkup(
<TrackingSnapshot summary={summary} knownWordsSummary={null} />,
);
assert.match(markup, /Unique headwords seen for the first time today/);
assert.match(markup, /Unique headwords seen for the first time this week/);
});
@@ -0,0 +1,149 @@
import type { OverviewSummary } from '../../lib/dashboard-data';
import { formatNumber } from '../../lib/formatters';
import { Tooltip } from '../layout/Tooltip';
interface KnownWordsSummary {
totalUniqueWords: number;
knownWordCount: number;
}
interface TrackingSnapshotProps {
summary: OverviewSummary;
showTrackedCardNote?: boolean;
knownWordsSummary: KnownWordsSummary | null;
}
export function TrackingSnapshot({
summary,
showTrackedCardNote = false,
knownWordsSummary,
}: TrackingSnapshotProps) {
const knownWordPercent =
knownWordsSummary && knownWordsSummary.totalUniqueWords > 0
? Math.round((knownWordsSummary.knownWordCount / knownWordsSummary.totalUniqueWords) * 100)
: null;
return (
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
<h3 className="text-sm font-semibold text-ctp-text">Tracking Snapshot</h3>
<p className="mt-1 mb-3 text-xs text-ctp-overlay2">
Lifetime totals sourced from summary tables.
</p>
{showTrackedCardNote && (
<div className="mb-3 rounded-lg border border-ctp-surface2 bg-ctp-surface1/50 px-3 py-2 text-xs text-ctp-subtext0">
No lifetime card totals in the summary table yet. New cards mined after this fix will
appear here.
</div>
)}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 text-sm">
<Tooltip text="Total immersion sessions recorded across all time">
<div className="rounded-lg bg-ctp-surface1/60 p-3">
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Sessions</div>
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-lavender">
{formatNumber(summary.totalSessions)}
</div>
</div>
</Tooltip>
<Tooltip text="Total active watch time across all sessions">
<div className="rounded-lg bg-ctp-surface1/60 p-3">
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Watch Time</div>
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-mauve">
{summary.allTimeMinutes < 60
? `${summary.allTimeMinutes}m`
: `${(summary.allTimeMinutes / 60).toFixed(1)}h`}
</div>
</div>
</Tooltip>
<Tooltip text="Number of distinct days with at least one session">
<div className="rounded-lg bg-ctp-surface1/60 p-3">
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Active Days</div>
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-peach">
{formatNumber(summary.activeDays)}
</div>
</div>
</Tooltip>
<Tooltip text="Average active watch time per session in minutes">
<div className="rounded-lg bg-ctp-surface1/60 p-3">
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Avg Session</div>
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-yellow">
{formatNumber(summary.averageSessionMinutes)}
<span className="text-sm text-ctp-overlay2 ml-0.5">min</span>
</div>
</div>
</Tooltip>
<Tooltip text="Total unique episodes (videos) watched across all anime">
<div className="rounded-lg bg-ctp-surface1/60 p-3">
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Episodes</div>
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-blue">
{formatNumber(summary.totalEpisodesWatched)}
</div>
</div>
</Tooltip>
<Tooltip text="Number of anime series fully completed">
<div className="rounded-lg bg-ctp-surface1/60 p-3">
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Anime</div>
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-sapphire">
{formatNumber(summary.totalAnimeCompleted)}
</div>
</div>
</Tooltip>
<Tooltip text="Total Anki cards mined from subtitle lines across all sessions">
<div className="rounded-lg bg-ctp-surface1/60 p-3">
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Cards Mined</div>
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-cards-mined">
{formatNumber(summary.totalTrackedCards)}
</div>
</div>
</Tooltip>
<Tooltip text="Lifetime Yomitan lookups normalized by total words seen">
<div className="rounded-lg bg-ctp-surface1/60 p-3">
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Lookup Rate</div>
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-flamingo">
{summary.lookupRate?.shortValue ?? '—'}
</div>
</div>
</Tooltip>
<Tooltip text="Total word occurrences encountered in today's sessions">
<div className="rounded-lg bg-ctp-surface1/60 p-3">
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Words Today</div>
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-sky">
{formatNumber(summary.todayTokens)}
</div>
</div>
</Tooltip>
<Tooltip text="Unique headwords seen for the first time today">
<div className="rounded-lg bg-ctp-surface1/60 p-3">
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">New Words Today</div>
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-rosewater">
{formatNumber(summary.newWordsToday)}
</div>
</div>
</Tooltip>
<Tooltip text="Unique headwords seen for the first time this week">
<div className="rounded-lg bg-ctp-surface1/60 p-3">
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">New Words</div>
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-pink">
{formatNumber(summary.newWordsThisWeek)}
</div>
</div>
</Tooltip>
{knownWordsSummary && knownWordsSummary.totalUniqueWords > 0 && (
<Tooltip text="Words matched against your known-words list out of all unique words seen">
<div className="rounded-lg bg-ctp-surface1/60 p-3">
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Known Words</div>
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-green">
{formatNumber(knownWordsSummary.knownWordCount)}
<span className="text-sm text-ctp-overlay2 ml-1">
/ {formatNumber(knownWordsSummary.totalUniqueWords)}
</span>
{knownWordPercent != null ? (
<span className="text-sm text-ctp-overlay2 ml-1">({knownWordPercent}%)</span>
) : null}
</div>
</div>
</Tooltip>
)}
</div>
</div>
);
}
@@ -0,0 +1,85 @@
import { useState } from 'react';
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer } from 'recharts';
import { epochDayToDate } from '../../lib/formatters';
import { CHART_THEME } from '../../lib/chart-theme';
import type { DailyRollup } from '../../types/stats';
interface WatchTimeChartProps {
rollups: DailyRollup[];
}
type Range = 14 | 30 | 90;
function formatActiveMinutes(value: number | string, _name?: string, _payload?: unknown) {
const minutes = Number(value);
return [`${Number.isFinite(minutes) ? minutes : 0} min`, 'Active Time'];
}
export function WatchTimeChart({ rollups }: WatchTimeChartProps) {
const [range, setRange] = useState<Range>(14);
const byDay = new Map<number, number>();
for (const r of rollups) {
byDay.set(r.rollupDayOrMonth, (byDay.get(r.rollupDayOrMonth) ?? 0) + r.totalActiveMin);
}
const chartData = Array.from(byDay.entries())
.sort(([dayA], [dayB]) => dayA - dayB)
.map(([day, mins]) => ({
date: epochDayToDate(day).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }),
minutes: Math.round(mins),
}))
.slice(-range);
const ranges: Range[] = [14, 30, 90];
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">Watch Time</h3>
<div className="flex bg-ctp-surface0 rounded-lg p-0.5 border border-ctp-surface1">
{ranges.map((r) => (
<button
key={r}
onClick={() => setRange(r)}
className={`px-2.5 py-1 text-xs rounded-md transition-colors ${
range === r
? 'bg-ctp-surface2 text-ctp-text shadow-sm'
: 'text-ctp-overlay2 hover:text-ctp-subtext0'
}`}
>
{r}d
</button>
))}
</div>
</div>
<ResponsiveContainer width="100%" height={160}>
<BarChart data={chartData}>
<XAxis
dataKey="date"
tick={{ fontSize: 10, fill: CHART_THEME.tick }}
axisLine={false}
tickLine={false}
/>
<YAxis
tick={{ fontSize: 10, fill: CHART_THEME.tick }}
axisLine={false}
tickLine={false}
width={30}
/>
<Tooltip
contentStyle={{
background: CHART_THEME.tooltipBg,
border: `1px solid ${CHART_THEME.tooltipBorder}`,
borderRadius: 6,
color: CHART_THEME.tooltipText,
fontSize: 12,
}}
labelStyle={{ color: CHART_THEME.tooltipLabel }}
formatter={formatActiveMinutes}
/>
<Bar dataKey="minutes" fill={CHART_THEME.barFill} radius={[3, 3, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
);
}