import { useState } from 'react'; import { useTrends, type TimeRange, type GroupBy } from '../../hooks/useTrends'; import { DateRangeSelector } from './DateRangeSelector'; import { TrendChart } from './TrendChart'; import { StackedTrendChart, type PerAnimeDataPoint } from './StackedTrendChart'; import { buildAnimeVisibilityOptions, filterHiddenAnimeData, pruneHiddenAnime, } from './anime-visibility'; import { buildTrendDashboard, type ChartPoint } from '../../lib/dashboard-data'; import { localDayFromMs } from '../../lib/formatters'; import type { SessionSummary } from '../../types/stats'; const DAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; function buildWatchTimeByDayOfWeek(sessions: SessionSummary[]): ChartPoint[] { const totals = new Array(7).fill(0); for (const s of sessions) { const dow = new Date(s.startedAtMs).getDay(); totals[dow] += s.activeWatchedMs; } return DAY_NAMES.map((name, i) => ({ label: name, value: Math.round(totals[i] / 60_000) })); } function buildWatchTimeByHour(sessions: SessionSummary[]): ChartPoint[] { const totals = new Array(24).fill(0); for (const s of sessions) { const hour = new Date(s.startedAtMs).getHours(); totals[hour] += s.activeWatchedMs; } return totals.map((ms, i) => ({ label: `${String(i).padStart(2, '0')}:00`, value: Math.round(ms / 60_000), })); } function buildCumulativePerAnime(points: PerAnimeDataPoint[]): PerAnimeDataPoint[] { const byAnime = new Map>(); const allDays = new Set(); for (const p of points) { const dayMap = byAnime.get(p.animeTitle) ?? new Map(); dayMap.set(p.epochDay, (dayMap.get(p.epochDay) ?? 0) + p.value); byAnime.set(p.animeTitle, dayMap); allDays.add(p.epochDay); } const sortedDays = [...allDays].sort((a, b) => a - b); if (sortedDays.length < 2) return points; const minDay = sortedDays[0]!; const maxDay = sortedDays[sortedDays.length - 1]!; const everyDay: number[] = []; for (let d = minDay; d <= maxDay; d++) { everyDay.push(d); } const result: PerAnimeDataPoint[] = []; for (const [animeTitle, dayMap] of byAnime) { let cumulative = 0; const firstDay = Math.min(...dayMap.keys()); for (const day of everyDay) { if (day < firstDay) continue; cumulative += dayMap.get(day) ?? 0; result.push({ epochDay: day, animeTitle, value: cumulative }); } } return result; } function buildPerAnimeFromSessions( sessions: SessionSummary[], getValue: (s: SessionSummary) => number, ): PerAnimeDataPoint[] { const map = new Map>(); for (const s of sessions) { const title = s.animeTitle ?? s.canonicalTitle ?? 'Unknown'; const day = localDayFromMs(s.startedAtMs); const animeMap = map.get(title) ?? new Map(); animeMap.set(day, (animeMap.get(day) ?? 0) + getValue(s)); map.set(title, animeMap); } const points: PerAnimeDataPoint[] = []; for (const [animeTitle, dayMap] of map) { for (const [epochDay, value] of dayMap) { points.push({ epochDay, animeTitle, value }); } } return points; } function buildEpisodesPerAnimeFromSessions(sessions: SessionSummary[]): PerAnimeDataPoint[] { // Group by anime+day, counting distinct videoIds const map = new Map>>(); for (const s of sessions) { const title = s.animeTitle ?? s.canonicalTitle ?? 'Unknown'; const day = localDayFromMs(s.startedAtMs); const animeMap = map.get(title) ?? new Map(); const videoSet = animeMap.get(day) ?? new Set(); videoSet.add(s.videoId); animeMap.set(day, videoSet); map.set(title, animeMap); } const points: PerAnimeDataPoint[] = []; for (const [animeTitle, dayMap] of map) { for (const [epochDay, videoSet] of dayMap) { points.push({ epochDay, animeTitle, value: videoSet.size }); } } return points; } function SectionHeader({ children }: { children: React.ReactNode }) { return (

{children}

); } interface AnimeVisibilityFilterProps { animeTitles: string[]; hiddenAnime: ReadonlySet; onShowAll: () => void; onHideAll: () => void; onToggleAnime: (title: string) => void; } function AnimeVisibilityFilter({ animeTitles, hiddenAnime, onShowAll, onHideAll, onToggleAnime, }: AnimeVisibilityFilterProps) { if (animeTitles.length === 0) { return null; } return (

Anime Visibility

Shared across all anime trend charts. Default: show everything.

{animeTitles.map((title) => { const isVisible = !hiddenAnime.has(title); return ( ); })}
); } export function TrendsTab() { const [range, setRange] = useState('30d'); const [groupBy, setGroupBy] = useState('day'); const [hiddenAnime, setHiddenAnime] = useState>(() => new Set()); const { data, loading, error } = useTrends(range, groupBy); if (loading) return
Loading...
; if (error) return
Error: {error}
; const dashboard = buildTrendDashboard(data.rollups); const watchByDow = buildWatchTimeByDayOfWeek(data.sessions); const watchByHour = buildWatchTimeByHour(data.sessions); const watchTimePerAnime = data.watchTimePerAnime.map((e) => ({ epochDay: e.epochDay, animeTitle: e.animeTitle, value: e.totalActiveMin, })); const episodesPerAnime = buildEpisodesPerAnimeFromSessions(data.sessions); const cardsPerAnime = buildPerAnimeFromSessions(data.sessions, (s) => s.cardsMined); const wordsPerAnime = buildPerAnimeFromSessions(data.sessions, (s) => s.wordsSeen); const animeProgress = buildCumulativePerAnime(episodesPerAnime); const cardsProgress = buildCumulativePerAnime(cardsPerAnime); const wordsProgress = buildCumulativePerAnime(wordsPerAnime); const animeTitles = buildAnimeVisibilityOptions([ episodesPerAnime, watchTimePerAnime, cardsPerAnime, wordsPerAnime, animeProgress, cardsProgress, wordsProgress, ]); const activeHiddenAnime = pruneHiddenAnime(hiddenAnime, animeTitles); const filteredEpisodesPerAnime = filterHiddenAnimeData(episodesPerAnime, activeHiddenAnime); const filteredWatchTimePerAnime = filterHiddenAnimeData(watchTimePerAnime, activeHiddenAnime); const filteredCardsPerAnime = filterHiddenAnimeData(cardsPerAnime, activeHiddenAnime); const filteredWordsPerAnime = filterHiddenAnimeData(wordsPerAnime, activeHiddenAnime); const filteredAnimeProgress = filterHiddenAnimeData(animeProgress, activeHiddenAnime); const filteredCardsProgress = filterHiddenAnimeData(cardsProgress, activeHiddenAnime); const filteredWordsProgress = filterHiddenAnimeData(wordsProgress, activeHiddenAnime); return (
Activity Anime — Per Day setHiddenAnime(new Set())} onHideAll={() => setHiddenAnime(new Set(animeTitles))} onToggleAnime={(title) => setHiddenAnime((current) => { const next = new Set(current); if (next.has(title)) { next.delete(title); } else { next.add(title); } return next; }) } /> Anime — Cumulative Patterns
); }