This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

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;