This repository has no description
1import React, { useState, useEffect } from 'react';
2import { Bar } from 'react-chartjs-2';
3import {
4 Chart as ChartJS,
5 CategoryScale,
6 LinearScale,
7 BarElement,
8 Title,
9 Tooltip,
10 Legend
11} from 'chart.js';
12import './ActivityChart.css';
13
14// Register Chart.js components
15ChartJS.register(
16 CategoryScale,
17 LinearScale,
18 BarElement,
19 Title,
20 Tooltip,
21 Legend
22);
23
24const ActivityChart = ({ records, collections, loading = false }) => {
25 const [timePeriod, setTimePeriod] = useState('7days');
26 const [chartData, setChartData] = useState({
27 labels: [],
28 datasets: []
29 });
30
31 // App color scheme
32 const bskyColor = 'rgba(0, 133, 255, 0.7)'; // Lighter blue for Bluesky
33 const bskyBorderColor = 'rgba(0, 133, 255, 1)';
34 const atprotoColor = 'rgba(0, 51, 102, 0.8)'; // Darker blue for ATProto
35 const atprotoBorderColor = 'rgba(0, 51, 102, 1)';
36
37 useEffect(() => {
38 // Generate chart data whenever records change or time period changes
39 // Don't wait for loading to be false - this ensures we regenerate as soon as we get new data
40 if (records && records.length > 0) {
41 console.log(`Generating chart data for ${records.length} records with period ${timePeriod}`);
42 generateChartData(records, timePeriod);
43 } else if (!loading) {
44 // If we have no records and we're not loading, reset chart data
45 setChartData({
46 labels: [],
47 datasets: []
48 });
49 }
50 }, [records, timePeriod]);
51
52 // Function to generate data for the chart based on selected time period
53 const generateChartData = (allRecords, period) => {
54 // Determine date range based on selected period
55 const currentDate = new Date();
56 let startDate;
57 let dateFormat;
58 let bucketSize;
59 let timeFormat;
60
61 switch (period) {
62 case '24hours':
63 startDate = new Date(currentDate);
64 startDate.setHours(currentDate.getHours() - 24);
65 dateFormat = { hour: '2-digit' }; // "05 PM"
66 bucketSize = 'hour';
67 timeFormat = true;
68 break;
69 case '7days':
70 startDate = new Date(currentDate);
71 startDate.setDate(currentDate.getDate() - 7);
72 dateFormat = { month: 'short', day: 'numeric' }; // "Jan 1"
73 bucketSize = 'day';
74 timeFormat = false;
75 break;
76 case '30days':
77 startDate = new Date(currentDate);
78 startDate.setDate(currentDate.getDate() - 30);
79 dateFormat = { month: 'short', day: 'numeric' }; // "Jan 1"
80 bucketSize = 'day';
81 timeFormat = false;
82 break;
83 case '90days':
84 startDate = new Date(currentDate);
85 startDate.setDate(currentDate.getDate() - 90);
86 // For 90 days, group by week instead of day to make it more readable
87 dateFormat = { month: 'short', day: 'numeric' };
88 bucketSize = 'week';
89 timeFormat = false;
90 break;
91 default:
92 startDate = new Date(currentDate);
93 startDate.setDate(currentDate.getDate() - 7);
94 dateFormat = { month: 'short', day: 'numeric' }; // "Jan 1"
95 bucketSize = 'day';
96 timeFormat = false;
97 }
98
99 // Create date buckets
100 const dateBuckets = {};
101 const labels = [];
102
103 // For 90 days with weekly buckets, calculate week numbers
104 if (bucketSize === 'week') {
105 // Create weekly buckets
106 let currentWeekStart = new Date(startDate);
107 // Adjust to start on Sunday or Monday (Sunday = 0, Monday = 1)
108 const dayOfWeek = currentWeekStart.getDay();
109 if (dayOfWeek !== 0) { // If not Sunday
110 // Adjust date to previous Sunday
111 currentWeekStart.setDate(currentWeekStart.getDate() - dayOfWeek);
112 }
113
114 while (currentWeekStart <= currentDate) {
115 const weekEndDate = new Date(currentWeekStart);
116 weekEndDate.setDate(weekEndDate.getDate() + 6); // End date is 6 days after start (for a full week)
117
118 const bucketKey = currentWeekStart.toISOString().split('T')[0];
119 const startLabel = currentWeekStart.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
120 const endLabel = weekEndDate.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
121 const weekLabel = `${startLabel} - ${endLabel}`;
122
123 dateBuckets[bucketKey] = {
124 label: weekLabel,
125 start: new Date(currentWeekStart), // Store start and end dates for filtering
126 end: new Date(weekEndDate),
127 bskyRecords: 0,
128 atprotoRecords: 0
129 };
130
131 labels.push(weekLabel);
132
133 // Move to next week
134 currentWeekStart.setDate(currentWeekStart.getDate() + 7);
135 }
136 }
137 else if (bucketSize === 'hour') {
138 // Create hourly buckets for 24-hour view
139 let currentHour = new Date(startDate);
140 currentHour.setMinutes(0, 0, 0); // Start at the beginning of the hour
141
142 while (currentHour <= currentDate) {
143 const hourKey = currentHour.toISOString();
144 let hourLabel;
145
146 if (timeFormat) {
147 hourLabel = currentHour.toLocaleTimeString('en-US', { hour: '2-digit' });
148 } else {
149 hourLabel = currentHour.toLocaleDateString('en-US', dateFormat);
150 }
151
152 dateBuckets[hourKey] = {
153 label: hourLabel,
154 timestamp: new Date(currentHour),
155 bskyRecords: 0,
156 atprotoRecords: 0
157 };
158
159 labels.push(hourLabel);
160
161 // Move to next hour
162 currentHour.setHours(currentHour.getHours() + 1);
163 }
164 }
165 else {
166 // Create daily buckets for 7-day and 30-day views
167 let currentDay = new Date(startDate);
168 currentDay.setHours(0, 0, 0, 0); // Start at the beginning of the day
169
170 while (currentDay <= currentDate) {
171 const dayKey = currentDay.toISOString().split('T')[0]; // YYYY-MM-DD
172 const dayLabel = currentDay.toLocaleDateString('en-US', dateFormat);
173
174 dateBuckets[dayKey] = {
175 label: dayLabel,
176 date: new Date(currentDay),
177 bskyRecords: 0,
178 atprotoRecords: 0
179 };
180
181 labels.push(dayLabel);
182
183 // Move to next day
184 currentDay.setDate(currentDay.getDate() + 1);
185 }
186 }
187
188 // Count records for each bucket
189 allRecords.forEach(record => {
190 // Use either content timestamp or rkey timestamp, prioritizing content
191 const timestamp = record.contentTimestamp || record.rkeyTimestamp;
192 if (!timestamp) return;
193
194 const recordDate = new Date(timestamp);
195
196 // Find the matching bucket based on the time period type
197 let matchingBucketKey = null;
198
199 if (bucketSize === 'week') {
200 // For weekly buckets, find the week that contains this record
201 for (const bucketKey in dateBuckets) {
202 const bucket = dateBuckets[bucketKey];
203 if (recordDate >= bucket.start && recordDate <= bucket.end) {
204 matchingBucketKey = bucketKey;
205 break;
206 }
207 }
208 }
209 else if (bucketSize === 'hour') {
210 // For hourly buckets, find the hour
211 const hourStart = new Date(recordDate);
212 hourStart.setMinutes(0, 0, 0);
213 matchingBucketKey = hourStart.toISOString();
214 }
215 else {
216 // For daily buckets, use the date key
217 matchingBucketKey = recordDate.toISOString().split('T')[0]; // YYYY-MM-DD
218 }
219
220 // Only count if within our date range
221 if (matchingBucketKey && dateBuckets[matchingBucketKey]) {
222 // Track Bluesky vs non-Bluesky records
223 if (record.collection.startsWith('app.bsky.')) {
224 dateBuckets[matchingBucketKey].bskyRecords += 1;
225 } else {
226 dateBuckets[matchingBucketKey].atprotoRecords += 1;
227 }
228 }
229 });
230
231 // Format data for Chart.js
232 const bskyData = [];
233 const atprotoData = [];
234
235 // Extract data in the same order as labels
236 labels.forEach(label => {
237 // Find the matching bucket by label
238 const bucket = Object.values(dateBuckets).find(b => b.label === label);
239
240 if (bucket) {
241 bskyData.push(bucket.bskyRecords);
242 atprotoData.push(bucket.atprotoRecords);
243 } else {
244 // Fallback (shouldn't happen)
245 bskyData.push(0);
246 atprotoData.push(0);
247 }
248 });
249
250 // Set the chart data for a stacked chart
251 setChartData({
252 labels,
253 datasets: [
254 {
255 label: 'Bluesky Records',
256 data: bskyData,
257 backgroundColor: bskyColor,
258 borderColor: bskyBorderColor,
259 borderWidth: 1
260 },
261 {
262 label: 'Other ATProto Records',
263 data: atprotoData,
264 backgroundColor: atprotoColor,
265 borderColor: atprotoBorderColor,
266 borderWidth: 1
267 }
268 ]
269 });
270 };
271
272 // Chart options for stacked bar
273 const options = {
274 responsive: true,
275 maintainAspectRatio: false,
276 plugins: {
277 legend: {
278 position: 'top',
279 },
280 title: {
281 display: true,
282 text: 'ATProto Activity'
283 },
284 tooltip: {
285 callbacks: {
286 title: (tooltipItems) => {
287 return tooltipItems[0].label;
288 },
289 label: (context) => {
290 const label = context.dataset.label || '';
291 const value = context.raw || 0;
292 return `${label}: ${value} record${value !== 1 ? 's' : ''}`;
293 },
294 // Add footer for total
295 footer: (tooltipItems) => {
296 let sum = 0;
297 tooltipItems.forEach(tooltipItem => {
298 sum += tooltipItem.parsed.y;
299 });
300 return `Total: ${sum} record${sum !== 1 ? 's' : ''}`;
301 }
302 }
303 }
304 },
305 scales: {
306 x: {
307 stacked: true,
308 title: {
309 display: true,
310 text: timePeriod === '24hours' ? 'Hour' : 'Date'
311 }
312 },
313 y: {
314 stacked: true,
315 beginAtZero: true,
316 title: {
317 display: true,
318 text: 'Number of Records'
319 }
320 }
321 }
322 };
323
324 // Show loading state when fetching records deeply
325 if (loading) {
326 console.log("ActivityChart rendering loading state");
327 return (
328 <div className="activity-chart-container">
329 <div className="activity-chart-loading">
330 <div className="chart-loading-spinner"></div>
331 <p>Loading record data for visualization...</p>
332 <p className="chart-loading-note">This may take a moment for accounts with many records</p>
333 </div>
334 </div>
335 );
336 } else {
337 console.log(`ActivityChart has ${records?.length || 0} records for ${collections?.length || 0} collections`);
338 }
339
340 // Ensure records array exists and has items
341 if (!records || records.length === 0) {
342 return (
343 <div className="activity-chart-container">
344 <div className="activity-chart-empty">
345 <p>No data available for chart visualization</p>
346 </div>
347 </div>
348 );
349 }
350
351 return (
352 <div className="activity-chart-container">
353 <div className="time-period-selector">
354 <button
355 className={`time-period-button ${timePeriod === '24hours' ? 'active' : ''}`}
356 onClick={() => setTimePeriod('24hours')}
357 >
358 Last 24 Hours
359 </button>
360 <button
361 className={`time-period-button ${timePeriod === '7days' ? 'active' : ''}`}
362 onClick={() => setTimePeriod('7days')}
363 >
364 Last 7 Days
365 </button>
366 <button
367 className={`time-period-button ${timePeriod === '30days' ? 'active' : ''}`}
368 onClick={() => setTimePeriod('30days')}
369 >
370 Last 30 Days
371 </button>
372 <button
373 className={`time-period-button ${timePeriod === '90days' ? 'active' : ''}`}
374 onClick={() => setTimePeriod('90days')}
375 >
376 Last 3 Months
377 </button>
378 </div>
379
380 <div className="chart-container">
381 <Bar data={chartData} options={options} height={300} />
382 </div>
383
384 <div className="chart-summary">
385 <p>
386 Showing activity across {collections ? collections.length : 0} collections
387 {records ? ` (${records.length} records)` : ''}.
388 </p>
389 </div>
390 </div>
391 );
392};
393
394export default ActivityChart;