From 2d05f1f5312a49aad89795275f299cca3e1fd95f Mon Sep 17 00:00:00 2001 From: Uno-Takashi Date: Tue, 23 Jun 2026 00:32:57 +0000 Subject: [PATCH] feat: render per-day stats with Chart.js and reaction icons Replace the hand-rolled per-day bar charts with Chart.js (chart.js + react-chartjs-2) so hovering shows the exact day and count via a proper tooltip. Render reaction rows with react-icons matching the player's reaction buttons instead of raw reaction_type labels. Co-Authored-By: Claude Opus 4.8 --- package.json | 2 + pnpm-lock.yaml | 30 +++++ src/components/stats/StatsDashboard.tsx | 156 ++++++++++++++++++++---- 3 files changed, 163 insertions(+), 25 deletions(-) diff --git a/package.json b/package.json index 7cd3c98..c393ef5 100644 --- a/package.json +++ b/package.json @@ -19,12 +19,14 @@ }, "dependencies": { "@radix-ui/react-slot": "^1.1.1", + "chart.js": "^4.5.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "framer-motion": "^12.40.0", "lucide-react": "^1.21.0", "next": "^15.5.4", "react": "^19.2.0", + "react-chartjs-2": "^5.3.1", "react-dom": "^19.2.0", "react-icons": "^5.4.0", "react-use": "^17.6.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 44231a3..6d9a434 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@radix-ui/react-slot': specifier: ^1.1.1 version: 1.3.0(@types/react@19.2.17)(react@19.2.7) + chart.js: + specifier: ^4.5.1 + version: 4.5.1 class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -29,6 +32,9 @@ importers: react: specifier: ^19.2.0 version: 19.2.7 + react-chartjs-2: + specifier: ^5.3.1 + version: 5.3.1(chart.js@4.5.1)(react@19.2.7) react-dom: specifier: ^19.2.0 version: 19.2.7(react@19.2.7) @@ -754,6 +760,9 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@kurkle/color@0.3.4': + resolution: {integrity: sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==} + '@mdx-js/react@3.1.1': resolution: {integrity: sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw==} peerDependencies: @@ -1735,6 +1744,10 @@ packages: resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} engines: {node: '>=18'} + chart.js@4.5.1: + resolution: {integrity: sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==} + engines: {pnpm: '>=8'} + check-error@2.1.3: resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} engines: {node: '>= 16'} @@ -2511,6 +2524,12 @@ packages: queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + react-chartjs-2@5.3.1: + resolution: {integrity: sha512-h5IPXKg9EXpjoBzUfyWJvllMjG2mQ4EiuHQFhms/AjUm0XSZHhyRy2xVmLXHKrtcdrPO4mnGqRtYoD0vp95A0A==} + peerDependencies: + chart.js: ^4.1.1 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-docgen-typescript@2.4.0: resolution: {integrity: sha512-ZtAp5XTO5HRzQctjPU0ybY0RRCQO19X/8fxn3w7y2VVTUbGHDKULPTL4ky3vB05euSgG5NpALhEhDPvQ56wvXg==} peerDependencies: @@ -3451,6 +3470,8 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@kurkle/color@0.3.4': {} + '@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.7)': dependencies: '@types/mdx': 2.0.14 @@ -4392,6 +4413,10 @@ snapshots: loupe: 3.2.1 pathval: 2.0.1 + chart.js@4.5.1: + dependencies: + '@kurkle/color': 0.3.4 + check-error@2.1.3: {} chokidar@5.0.0: @@ -5203,6 +5228,11 @@ snapshots: queue-microtask@1.2.3: {} + react-chartjs-2@5.3.1(chart.js@4.5.1)(react@19.2.7): + dependencies: + chart.js: 4.5.1 + react: 19.2.7 + react-docgen-typescript@2.4.0(typescript@6.0.3): dependencies: typescript: 6.0.3 diff --git a/src/components/stats/StatsDashboard.tsx b/src/components/stats/StatsDashboard.tsx index 76b06b2..eec3ac4 100644 --- a/src/components/stats/StatsDashboard.tsx +++ b/src/components/stats/StatsDashboard.tsx @@ -1,5 +1,23 @@ "use client"; +import { useMemo } from "react"; +import { + BarElement, + CategoryScale, + Chart as ChartJS, + LinearScale, + Tooltip, + type ChartOptions, +} from "chart.js"; +import { Bar as BarChartJS } from "react-chartjs-2"; +import type { IconType } from "react-icons"; +import { + FaHandMiddleFinger, + FaHeart, + FaSadCry, + FaSmileBeam, + FaThumbsUp, +} from "react-icons/fa"; import { useAsync } from "react-use"; import { @@ -25,8 +43,35 @@ type Totals = { type ReactionRow = { reaction_type: string; count: number }; +/** + * Icon + label per reaction type, keyed by the backend `reaction_type` label + * (`streamer.models.ReactionType`). Icons mirror the player's reaction buttons + * (`react-icons/fa`) so the stats match what users tap in the extension. + */ +const REACTION_META: Record = { + favorite: { Icon: FaHeart, label: "お気に入り" }, + thumbs_up: { Icon: FaThumbsUp, label: "いいね" }, + smile: { Icon: FaSmileBeam, label: "笑顔" }, + cry: { Icon: FaSadCry, label: "涙" }, + middle_finger: { Icon: FaHandMiddleFinger, label: "ブーイング" }, +}; + type Bar = { label: string; value: number }; +ChartJS.register(CategoryScale, LinearScale, BarElement, Tooltip); + +/** + * Resolve a CSS custom property to a concrete color string for the canvas. + * Chart.js draws on a , which cannot resolve `var(--…)` itself. + */ +function cssColor(name: string, fallback: string): string { + if (typeof window === "undefined") return fallback; + const value = getComputedStyle(document.documentElement) + .getPropertyValue(name) + .trim(); + return value || fallback; +} + /** Pseudo-random but stable heights so the skeleton bars look chart-like. */ const SKELETON_BAR_HEIGHTS = [40, 65, 30, 80, 55, 70, 45, 90, 35, 60, 50, 75]; @@ -51,14 +96,71 @@ function BarChartSkeleton(): React.JSX.Element { ); } -/** Dependency-free responsive bar chart for the per-day series. */ +/** + * Responsive per-day bar chart built on Chart.js. + * + * Visually matches the previous hand-rolled bars (thin primary bars with + * rounded tops, no axes, first/最大/last footer) but adds a proper hover + * tooltip so the exact day and count are readable on mouse-over. + */ function BarChart({ data, loading, + unit, }: { data: Bar[]; loading: boolean; + unit: string; }): React.JSX.Element { + const chart = useMemo(() => { + const primary = cssColor("--primary", "oklch(0.637 0.237 25.331)"); + const card = cssColor("--card", "oklch(0.215 0.01 270)"); + const cardForeground = cssColor("--card-foreground", "oklch(0.985 0 0)"); + const barColor = `color-mix(in oklch, ${primary} 80%, transparent)`; + + const chartData = { + labels: data.map((d) => d.label), + datasets: [ + { + data: data.map((d) => d.value), + backgroundColor: barColor, + hoverBackgroundColor: primary, + borderRadius: 4, + borderSkipped: "bottom" as const, + categoryPercentage: 1, + barPercentage: 0.9, + }, + ], + }; + + const options: ChartOptions<"bar"> = { + responsive: true, + maintainAspectRatio: false, + animation: false, + scales: { + x: { display: false }, + y: { display: false, beginAtZero: true }, + }, + plugins: { + legend: { display: false }, + tooltip: { + backgroundColor: card, + titleColor: cardForeground, + bodyColor: cardForeground, + borderColor: primary, + borderWidth: 1, + padding: 8, + displayColors: false, + callbacks: { + label: (item) => `${item.formattedValue} ${unit}`, + }, + }, + }, + }; + + return { chartData, options }; + }, [data, unit]); + if (loading) { return ; } @@ -70,15 +172,8 @@ function BarChart({ const last = data[data.length - 1]?.label ?? ""; return (
-
- {data.map((d, i) => ( -
- ))} +
+
{first} @@ -188,11 +283,11 @@ export function StatsDashboard(): React.JSX.Element {

1日ごとのユーザー数

- +

1日ごとのルーム数

- +
@@ -202,7 +297,7 @@ export function StatsDashboard(): React.JSX.Element {
    {Array.from({ length: 5 }).map((_, i) => (
  • - + データがありません。

    ) : (
      - {reactions.map((r) => ( -
    • - - {r.reaction_type} - - - {r.count.toLocaleString()} -
    • - ))} + {reactions.map((r) => { + const meta = REACTION_META[r.reaction_type]; + return ( +
    • + + {meta ? ( + + ) : ( + {r.reaction_type} + )} + + + {r.count.toLocaleString()} +
    • + ); + })}
    )}