mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-04-10 04:19:25 -07:00
- Guard episode deletion against double-submit with an isDeletingRef + setIsDeleting pair threaded through buildDeleteEpisodeHandler, and disable the MediaHeader delete button while a request is in flight. - Restore MediaHeader title truncation by adding min-w-0 flex-1 to the h2 so long titles shrink instead of pushing the delete button away. - Normalize the headword in FrequencyRankTable before comparing it to the (hiragana-normalized) reading so katakana-only entries like カレー no longer render a redundant 【かれー】. Test strengthened to reject any bracketed reading, not just the literal. - Rewrite confirmBucketDelete copy to include the "and all associated data" warning and handle singular/plural cleanly. - Run Prettier across the stats files CI was complaining about (EpisodeDetail, WatchTimeChart, SessionsTab + test, FrequencyRankTable + test, session-grouping test) to clear the format:check:stats gate.
81 lines
3.0 KiB
TypeScript
81 lines
3.0 KiB
TypeScript
import { useState } from 'react';
|
|
import { BarChart, Bar, CartesianGrid, XAxis, YAxis, Tooltip, ResponsiveContainer } from 'recharts';
|
|
import { epochDayToDate } from '../../lib/formatters';
|
|
import { CHART_DEFAULTS, CHART_THEME, TOOLTIP_CONTENT_STYLE } 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={CHART_DEFAULTS.height}>
|
|
<BarChart data={chartData} margin={CHART_DEFAULTS.margin}>
|
|
<CartesianGrid stroke={CHART_THEME.grid} {...CHART_DEFAULTS.grid} />
|
|
<XAxis
|
|
dataKey="date"
|
|
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
|
|
contentStyle={TOOLTIP_CONTENT_STYLE}
|
|
labelStyle={{ color: CHART_THEME.tooltipLabel }}
|
|
formatter={formatActiveMinutes}
|
|
/>
|
|
<Bar dataKey="minutes" fill={CHART_THEME.barFill} radius={[3, 3, 0, 0]} />
|
|
</BarChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
);
|
|
}
|