Files
Tracearr/apps/web/src/components/charts/PlaysChart.tsx
Rephl3x 3015f48118
Some checks failed
CI / Lint & Typecheck (push) Has been cancelled
CI / Test (routes) (push) Has been cancelled
CI / Test (security) (push) Has been cancelled
CI / Test (services) (push) Has been cancelled
CI / Test (unit) (push) Has been cancelled
CI / Test (integration) (push) Has been cancelled
CI / Test Coverage (push) Has been cancelled
CI / Build (push) Has been cancelled
Initial Upload
2025-12-17 12:32:50 +13:00

182 lines
4.9 KiB
TypeScript

import { useMemo } from 'react';
import Highcharts from 'highcharts';
import HighchartsReact from 'highcharts-react-official';
import type { PlayStats } from '@tracearr/shared';
import { ChartSkeleton } from '@/components/ui/skeleton';
interface PlaysChartProps {
data: PlayStats[] | undefined;
isLoading?: boolean;
height?: number;
period?: 'day' | 'week' | 'month' | 'year' | 'all' | 'custom';
}
export function PlaysChart({ data, isLoading, height = 200, period = 'month' }: PlaysChartProps) {
const options = useMemo<Highcharts.Options>(() => {
if (!data || data.length === 0) {
return {};
}
return {
chart: {
type: 'area',
height,
backgroundColor: 'transparent',
style: {
fontFamily: 'inherit',
},
reflow: true,
},
title: {
text: undefined,
},
credits: {
enabled: false,
},
legend: {
enabled: false,
},
xAxis: {
categories: data.map((d) => d.date),
labels: {
style: {
color: 'hsl(var(--muted-foreground))',
},
formatter: function () {
// this.value could be index (number) or category string depending on Highcharts version
const categories = this.axis.categories;
const categoryValue = typeof this.value === 'number'
? categories[this.value]
: this.value;
if (!categoryValue) return '';
const date = new Date(categoryValue);
if (isNaN(date.getTime())) return '';
if (period === 'year') {
// Short month name for yearly view (Dec, Jan, Feb)
return date.toLocaleDateString('en-US', { month: 'short' });
}
// M/D format for week/month views
return `${date.getMonth() + 1}/${date.getDate()}`;
},
step: Math.ceil(data.length / 12), // Show ~12 labels
},
lineColor: 'hsl(var(--border))',
tickColor: 'hsl(var(--border))',
},
yAxis: {
title: {
text: undefined,
},
labels: {
style: {
color: 'hsl(var(--muted-foreground))',
},
},
gridLineColor: 'hsl(var(--border))',
min: 0,
},
plotOptions: {
area: {
fillColor: {
linearGradient: { x1: 0, y1: 0, x2: 0, y2: 1 },
stops: [
[0, 'hsl(var(--primary) / 0.3)'],
[1, 'hsl(var(--primary) / 0.05)'],
],
},
marker: {
enabled: false,
states: {
hover: {
enabled: true,
radius: 4,
},
},
},
lineWidth: 2,
lineColor: 'hsl(var(--primary))',
states: {
hover: {
lineWidth: 2,
},
},
threshold: null,
},
},
tooltip: {
backgroundColor: 'hsl(var(--popover))',
borderColor: 'hsl(var(--border))',
style: {
color: 'hsl(var(--popover-foreground))',
},
formatter: function () {
// With categories, this.x is the index. Use this.point.category for the actual value
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const categoryValue = (this as any).point?.category as string | undefined;
const date = categoryValue ? new Date(categoryValue) : null;
const dateStr = date && !isNaN(date.getTime())
? date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
: 'Unknown';
return `<b>${dateStr}</b><br/>Plays: ${this.y}`;
},
},
series: [
{
type: 'area',
name: 'Plays',
data: data.map((d) => d.count),
},
],
responsive: {
rules: [
{
condition: {
maxWidth: 400,
},
chartOptions: {
xAxis: {
labels: {
style: {
fontSize: '9px',
},
step: Math.ceil(data.length / 6),
},
},
yAxis: {
labels: {
style: {
fontSize: '9px',
},
},
},
},
},
],
},
};
}, [data, height, period]);
if (isLoading) {
return <ChartSkeleton height={height} />;
}
if (!data || data.length === 0) {
return (
<div
className="flex items-center justify-center rounded-lg border border-dashed text-muted-foreground"
style={{ height }}
>
No play data available
</div>
);
}
return (
<HighchartsReact
highcharts={Highcharts}
options={options}
containerProps={{ style: { width: '100%', height: '100%' } }}
/>
);
}