Demo using Slices Network GraphQL Relay API to make a teal.fm client
1import { graphql, useFragment } from "react-relay";
2import { useMemo } from "react";
3import type { ScrobbleChart_data$key } from "./__generated__/ScrobbleChart_data.graphql";
4
5interface ScrobbleChartProps {
6 queryRef: ScrobbleChart_data$key;
7}
8
9export default function ScrobbleChart({ queryRef }: ScrobbleChartProps) {
10 const data = useFragment(
11 graphql`
12 fragment ScrobbleChart_data on Query {
13 chartData: fmTealAlphaFeedPlayAggregated(
14 groupBy: [{ field: playedTime, interval: DAY }]
15 where: $chartWhere
16 limit: 90
17 ) {
18 playedTime
19 count
20 }
21 }
22 `,
23 queryRef,
24 );
25
26 const chartData = useMemo(() => {
27 if (!data?.chartData) return [];
28
29 // Convert aggregated data to chart format
30 const aggregated = data.chartData.map((item) => {
31 // playedTime comes back as '2025-08-03 00:00:00', extract just the date part
32 const date = item.playedTime ? item.playedTime.split(" ")[0] : "";
33 return {
34 date,
35 count: item.count,
36 };
37 }).sort((a, b) => a.date.localeCompare(b.date));
38
39 // Fill in missing days with zero counts
40 const now = new Date();
41 now.setHours(0, 0, 0, 0);
42 const filledData = [];
43
44 for (let i = 89; i >= 0; i--) {
45 const date = new Date(now);
46 date.setDate(date.getDate() - i);
47 const dateStr = date.toISOString().split("T")[0];
48
49 const existing = aggregated.find((d) => d.date === dateStr);
50 filledData.push({
51 date: dateStr,
52 count: existing ? existing.count : 0,
53 });
54 }
55
56 return filledData;
57 }, [data?.chartData]);
58
59 if (!chartData || chartData.length === 0) return null;
60
61 const width = 1000;
62 const height = 100;
63 const padding = { top: 0, right: 0, bottom: 0, left: 0 };
64 const chartWidth = width - padding.left - padding.right;
65 const chartHeight = height - padding.top - padding.bottom;
66
67 const maxCount = Math.max(...chartData.map((d) => d.count));
68 const minCount = Math.min(...chartData.map((d) => d.count));
69 const range = maxCount - minCount || 1;
70
71 // Generate points for the line
72 const points = chartData.map((d, i) => {
73 const x = padding.left + (i / (chartData.length - 1)) * chartWidth;
74 const y = padding.top + chartHeight -
75 ((d.count - minCount) / range) * chartHeight;
76 return `${x},${y}`;
77 }).join(" ");
78
79 // Generate area path
80 const areaPoints = [
81 `${padding.left},${padding.top + chartHeight}`,
82 ...chartData.map((d, i) => {
83 const x = padding.left + (i / (chartData.length - 1)) * chartWidth;
84 const y = padding.top + chartHeight -
85 ((d.count - minCount) / range) * chartHeight;
86 return `${x},${y}`;
87 }),
88 `${padding.left + chartWidth},${padding.top + chartHeight}`,
89 ].join(" ");
90
91 return (
92 <svg
93 viewBox={`0 0 ${width} ${height}`}
94 className="w-full h-full"
95 preserveAspectRatio="none"
96 >
97 {/* Area fill */}
98 <polygon
99 points={areaPoints}
100 fill="rgb(139 92 246 / 0.1)"
101 stroke="none"
102 />
103
104 {/* Line */}
105 <polyline
106 points={points}
107 fill="none"
108 stroke="rgb(139 92 246)"
109 strokeWidth="1.5"
110 strokeLinecap="round"
111 strokeLinejoin="round"
112 />
113 </svg>
114 );
115}