Initial Upload
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
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
This commit is contained in:
181
apps/web/src/components/charts/PlaysChart.tsx
Normal file
181
apps/web/src/components/charts/PlaysChart.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
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%' } }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user