This repository has no description
0

Configure Feed

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

add chart

+394 -4
+30
package-lock.json
··· 18 18 "@vercel/analytics": "^1.5.0", 19 19 "@vercel/edge": "^1.2.1", 20 20 "axios": "^1.7.9", 21 + "chart.js": "^4.4.8", 21 22 "date-fns": "^4.1.0", 22 23 "express-session": "^1.18.1", 23 24 "jsonwebtoken": "^9.0.2", ··· 25 26 "matter-js": "^0.20.0", 26 27 "prop-types": "^15.8.1", 27 28 "react": "^18.2.0", 29 + "react-chartjs-2": "^5.3.0", 28 30 "react-dom": "^18.2.0", 29 31 "react-grid-layout": "^1.5.0", 30 32 "react-helmet": "^6.1.0", ··· 3423 3425 "@jridgewell/sourcemap-codec": "^1.4.14" 3424 3426 } 3425 3427 }, 3428 + "node_modules/@kurkle/color": { 3429 + "version": "0.3.4", 3430 + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", 3431 + "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", 3432 + "license": "MIT" 3433 + }, 3426 3434 "node_modules/@leichtgewicht/ip-codec": { 3427 3435 "version": "2.0.5", 3428 3436 "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz", ··· 6236 6244 "license": "MIT", 6237 6245 "engines": { 6238 6246 "node": ">=10" 6247 + } 6248 + }, 6249 + "node_modules/chart.js": { 6250 + "version": "4.4.8", 6251 + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.4.8.tgz", 6252 + "integrity": "sha512-IkGZlVpXP+83QpMm4uxEiGqSI7jFizwVtF3+n5Pc3k7sMO+tkd0qxh2OzLhenM0K80xtmAONWGBn082EiBQSDA==", 6253 + "license": "MIT", 6254 + "dependencies": { 6255 + "@kurkle/color": "^0.3.0" 6256 + }, 6257 + "engines": { 6258 + "pnpm": ">=8" 6239 6259 } 6240 6260 }, 6241 6261 "node_modules/check-types": { ··· 14877 14897 "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", 14878 14898 "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", 14879 14899 "license": "MIT" 14900 + }, 14901 + "node_modules/react-chartjs-2": { 14902 + "version": "5.3.0", 14903 + "resolved": "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-5.3.0.tgz", 14904 + "integrity": "sha512-UfZZFnDsERI3c3CZGxzvNJd02SHjaSJ8kgW1djn65H1KK8rehwTjyrRKOG3VTMG8wtHZ5rgAO5oTHtHi9GCCmw==", 14905 + "license": "MIT", 14906 + "peerDependencies": { 14907 + "chart.js": "^4.1.1", 14908 + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" 14909 + } 14880 14910 }, 14881 14911 "node_modules/react-dev-utils": { 14882 14912 "version": "12.0.1",
+2
package.json
··· 13 13 "@vercel/analytics": "^1.5.0", 14 14 "@vercel/edge": "^1.2.1", 15 15 "axios": "^1.7.9", 16 + "chart.js": "^4.4.8", 16 17 "date-fns": "^4.1.0", 17 18 "express-session": "^1.18.1", 18 19 "jsonwebtoken": "^9.0.2", ··· 20 21 "matter-js": "^0.20.0", 21 22 "prop-types": "^15.8.1", 22 23 "react": "^18.2.0", 24 + "react-chartjs-2": "^5.3.0", 23 25 "react-dom": "^18.2.0", 24 26 "react-grid-layout": "^1.5.0", 25 27 "react-helmet": "^6.1.0",
+74
src/components/CollectionsFeed/ActivityChart.css
··· 1 + /* ActivityChart.css */ 2 + 3 + .activity-chart-container { 4 + background-color: var(--navbar-bg); 5 + border: 1px solid var(--card-border); 6 + border-radius: 8px; 7 + padding: 20px; 8 + margin-bottom: 20px; 9 + } 10 + 11 + .time-period-selector { 12 + display: flex; 13 + justify-content: center; 14 + margin-bottom: 15px; 15 + flex-wrap: wrap; 16 + gap: 10px; 17 + } 18 + 19 + .time-period-button { 20 + background-color: var(--navbar-bg); 21 + color: var(--text); 22 + border: 1px solid var(--card-border); 23 + border-radius: 4px; 24 + padding: 8px 12px; 25 + font-size: 0.9rem; 26 + cursor: pointer; 27 + transition: all 0.2s ease; 28 + } 29 + 30 + .time-period-button:hover { 31 + border-color: var(--button-bg); 32 + } 33 + 34 + .time-period-button.active { 35 + background-color: var(--button-bg); 36 + color: var(--button-text); 37 + border-color: var(--button-bg); 38 + } 39 + 40 + .chart-container { 41 + height: 300px; 42 + position: relative; 43 + } 44 + 45 + .chart-summary { 46 + margin-top: 15px; 47 + text-align: center; 48 + font-size: 0.9rem; 49 + color: var(--text); 50 + opacity: 0.8; 51 + } 52 + 53 + .activity-chart-empty { 54 + height: 300px; 55 + display: flex; 56 + justify-content: center; 57 + align-items: center; 58 + color: var(--text); 59 + opacity: 0.7; 60 + border: 1px dashed var(--card-border); 61 + border-radius: 6px; 62 + margin: 15px 0; 63 + } 64 + 65 + @media (max-width: 768px) { 66 + .time-period-selector { 67 + flex-direction: column; 68 + align-items: stretch; 69 + } 70 + 71 + .time-period-button { 72 + width: 100%; 73 + } 74 + }
+253
src/components/CollectionsFeed/ActivityChart.js
··· 1 + import React, { useState, useEffect } from 'react'; 2 + import { Bar } from 'react-chartjs-2'; 3 + import { 4 + Chart as ChartJS, 5 + CategoryScale, 6 + LinearScale, 7 + BarElement, 8 + Title, 9 + Tooltip, 10 + Legend 11 + } from 'chart.js'; 12 + import './ActivityChart.css'; 13 + 14 + // Register Chart.js components 15 + ChartJS.register( 16 + CategoryScale, 17 + LinearScale, 18 + BarElement, 19 + Title, 20 + Tooltip, 21 + Legend 22 + ); 23 + 24 + const ActivityChart = ({ records, collections }) => { 25 + const [timePeriod, setTimePeriod] = useState('7days'); 26 + const [chartData, setChartData] = useState({ 27 + labels: [], 28 + datasets: [] 29 + }); 30 + 31 + useEffect(() => { 32 + // Only generate chart data if we have records 33 + if (records && records.length > 0) { 34 + generateChartData(records, timePeriod); 35 + } 36 + }, [records, timePeriod]); 37 + 38 + // Function to generate data for the chart based on selected time period 39 + const generateChartData = (allRecords, period) => { 40 + // Determine date range based on selected period 41 + const currentDate = new Date(); 42 + let startDate; 43 + let dateFormat; 44 + 45 + switch (period) { 46 + case '7days': 47 + startDate = new Date(currentDate); 48 + startDate.setDate(currentDate.getDate() - 7); 49 + dateFormat = { month: 'short', day: 'numeric' }; // "Jan 1" 50 + break; 51 + case '30days': 52 + startDate = new Date(currentDate); 53 + startDate.setDate(currentDate.getDate() - 30); 54 + dateFormat = { month: 'short', day: 'numeric' }; // "Jan 1" 55 + break; 56 + case '90days': 57 + startDate = new Date(currentDate); 58 + startDate.setDate(currentDate.getDate() - 90); 59 + dateFormat = { month: 'short' }; // "January" 60 + break; 61 + default: 62 + startDate = new Date(currentDate); 63 + startDate.setDate(currentDate.getDate() - 7); 64 + dateFormat = { month: 'short', day: 'numeric' }; // "Jan 1" 65 + } 66 + 67 + // Create date buckets 68 + const dateBuckets = {}; 69 + const labels = []; 70 + 71 + // Initialize date buckets based on the selected time period 72 + let currentBucket = new Date(startDate); 73 + 74 + while (currentBucket <= currentDate) { 75 + const dateKey = currentBucket.toISOString().split('T')[0]; // YYYY-MM-DD 76 + const formattedDate = currentBucket.toLocaleDateString('en-US', dateFormat); 77 + 78 + dateBuckets[dateKey] = { 79 + date: formattedDate, 80 + total: 0, 81 + bskyRecords: 0, 82 + nonBskyRecords: 0 83 + }; 84 + 85 + labels.push(formattedDate); 86 + 87 + // Move to next day 88 + currentBucket.setDate(currentBucket.getDate() + 1); 89 + } 90 + 91 + // Count records for each date 92 + allRecords.forEach(record => { 93 + // Use either content timestamp or rkey timestamp, prioritizing content 94 + const timestamp = record.contentTimestamp || record.rkeyTimestamp; 95 + if (!timestamp) return; 96 + 97 + const recordDate = new Date(timestamp); 98 + const dateKey = recordDate.toISOString().split('T')[0]; // YYYY-MM-DD 99 + 100 + // Only count if within our date range 101 + if (dateBuckets[dateKey]) { 102 + dateBuckets[dateKey].total += 1; 103 + 104 + // Also track Bluesky vs non-Bluesky records 105 + if (record.collection.startsWith('app.bsky.')) { 106 + dateBuckets[dateKey].bskyRecords += 1; 107 + } else { 108 + dateBuckets[dateKey].nonBskyRecords += 1; 109 + } 110 + } 111 + }); 112 + 113 + // Format data for Chart.js 114 + const totalData = []; 115 + const bskyData = []; 116 + const nonBskyData = []; 117 + 118 + // Extract data in the same order as labels 119 + labels.forEach(label => { 120 + // Find the matching bucket by formatted date 121 + const bucket = Object.values(dateBuckets).find(b => b.date === label); 122 + 123 + if (bucket) { 124 + totalData.push(bucket.total); 125 + bskyData.push(bucket.bskyRecords); 126 + nonBskyData.push(bucket.nonBskyRecords); 127 + } else { 128 + // Fallback (shouldn't happen) 129 + totalData.push(0); 130 + bskyData.push(0); 131 + nonBskyData.push(0); 132 + } 133 + }); 134 + 135 + // Set the chart data 136 + setChartData({ 137 + labels, 138 + datasets: [ 139 + { 140 + label: 'All Records', 141 + data: totalData, 142 + backgroundColor: 'rgba(0, 133, 255, 0.6)', 143 + borderColor: 'rgba(0, 133, 255, 1)', 144 + borderWidth: 1 145 + }, 146 + { 147 + label: 'Bluesky Records', 148 + data: bskyData, 149 + backgroundColor: 'rgba(75, 192, 192, 0.6)', 150 + borderColor: 'rgba(75, 192, 192, 1)', 151 + borderWidth: 1 152 + }, 153 + { 154 + label: 'Other ATProto Records', 155 + data: nonBskyData, 156 + backgroundColor: 'rgba(153, 102, 255, 0.6)', 157 + borderColor: 'rgba(153, 102, 255, 1)', 158 + borderWidth: 1 159 + } 160 + ] 161 + }); 162 + }; 163 + 164 + // Chart options 165 + const options = { 166 + responsive: true, 167 + maintainAspectRatio: false, 168 + plugins: { 169 + legend: { 170 + position: 'top', 171 + }, 172 + title: { 173 + display: true, 174 + text: 'ATProto Activity by Date' 175 + }, 176 + tooltip: { 177 + callbacks: { 178 + title: (tooltipItems) => { 179 + return tooltipItems[0].label; 180 + }, 181 + label: (context) => { 182 + const label = context.dataset.label || ''; 183 + const value = context.raw || 0; 184 + return `${label}: ${value} record${value !== 1 ? 's' : ''}`; 185 + } 186 + } 187 + } 188 + }, 189 + scales: { 190 + x: { 191 + title: { 192 + display: true, 193 + text: 'Date' 194 + } 195 + }, 196 + y: { 197 + beginAtZero: true, 198 + title: { 199 + display: true, 200 + text: 'Number of Records' 201 + } 202 + } 203 + } 204 + }; 205 + 206 + // Ensure records array exists and has items 207 + if (!records || records.length === 0) { 208 + return ( 209 + <div className="activity-chart-container"> 210 + <div className="activity-chart-empty"> 211 + <p>No data available for chart visualization</p> 212 + </div> 213 + </div> 214 + ); 215 + } 216 + 217 + return ( 218 + <div className="activity-chart-container"> 219 + <div className="time-period-selector"> 220 + <button 221 + className={`time-period-button ${timePeriod === '7days' ? 'active' : ''}`} 222 + onClick={() => setTimePeriod('7days')} 223 + > 224 + Last 7 Days 225 + </button> 226 + <button 227 + className={`time-period-button ${timePeriod === '30days' ? 'active' : ''}`} 228 + onClick={() => setTimePeriod('30days')} 229 + > 230 + Last 30 Days 231 + </button> 232 + <button 233 + className={`time-period-button ${timePeriod === '90days' ? 'active' : ''}`} 234 + onClick={() => setTimePeriod('90days')} 235 + > 236 + Last 3 Months 237 + </button> 238 + </div> 239 + 240 + <div className="chart-container"> 241 + <Bar data={chartData} options={options} height={300} /> 242 + </div> 243 + 244 + <div className="chart-summary"> 245 + <p> 246 + Showing activity across {collections ? collections.length : 0} collections. 247 + </p> 248 + </div> 249 + </div> 250 + ); 251 + }; 252 + 253 + export default ActivityChart;
+15
src/components/CollectionsFeed/CollectionsFeed.css
··· 1 1 /* Omnifeed.css (renamed from CollectionsFeed.css) */ 2 2 3 + /* Page Title Styles */ 4 + .page-title { 5 + text-align: center; 6 + margin-bottom: 10px; 7 + padding-bottom: 10px; 8 + border-bottom: 1px solid var(--card-border); 9 + } 10 + 11 + .page-title h1 { 12 + font-size: 2.2rem; 13 + margin: 0; 14 + color: var(--button-bg); 15 + font-weight: 700; 16 + } 17 + 3 18 .collections-feed-container { 4 19 max-width: 800px; 5 20 margin: 20px auto;
+20 -4
src/components/CollectionsFeed/CollectionsFeed.js
··· 2 2 import { useParams, useNavigate } from 'react-router-dom'; 3 3 import SearchBar from '../SearchBar/SearchBar'; 4 4 import FeedTimeline from './FeedTimeline'; 5 + import ActivityChart from './ActivityChart'; 5 6 import './CollectionsFeed.css'; // Renamed to Omnifeed.css but keeping same filename for compatibility 6 7 import { resolveHandleToDid, getServiceEndpointForDid } from '../../accountData'; 7 8 import MatterLoadingAnimation from '../MatterLoadingAnimation'; ··· 19 20 const [collections, setCollections] = useState([]); 20 21 const [selectedCollections, setSelectedCollections] = useState([]); 21 22 const [records, setRecords] = useState([]); 23 + const [allRecordsForChart, setAllRecordsForChart] = useState([]); // All records for chart visualization 22 24 const [loading, setLoading] = useState(false); 23 25 const [initialLoad, setInitialLoad] = useState(true); 24 26 const [error, setError] = useState(''); ··· 181 183 let allRecords = isLoadMore ? [...records] : []; 182 184 const newCursors = { ...collectionCursors }; 183 185 184 - // Calculate a cutoff date (90 days ago) 186 + // Calculate a cutoff date (90 days ago by default for chart visualization) 185 187 const cutoffDate = new Date(); 186 - cutoffDate.setDate(cutoffDate.getDate() - 90); 188 + cutoffDate.setDate(cutoffDate.getDate() - 90); // 3 months 187 189 188 190 // Fetch records for each collection in parallel 189 191 const fetchPromises = collectionsList.map(async (collection) => { ··· 255 257 return new Date(bTime) - new Date(aTime); // Newest first 256 258 }); 257 259 258 - // If not loading more, limit to 50 most recent records for initial display 260 + // We'll keep all records for charting purposes, but only show the most recent ones in the timeline 261 + const chartRecords = [...allRecords]; 262 + 263 + // If not loading more, limit to 20 most recent records for initial display in timeline 259 264 if (!isLoadMore) { 260 - allRecords = allRecords.slice(0, 50); 265 + allRecords = allRecords.slice(0, 20); 261 266 } 262 267 268 + // Update state with both the display records and the full set for charting 263 269 setRecords(allRecords); 270 + setAllRecordsForChart(prev => isLoadMore ? [...prev, ...chartRecords] : chartRecords); 264 271 setCollectionCursors(newCursors); 265 272 setFetchingMore(false); 266 273 } catch (err) { ··· 373 380 </div> 374 381 ) : searchPerformed && ( 375 382 <div className="feed-container"> 383 + <div className="page-title"> 384 + <h1>Omnifeed</h1> 385 + </div> 376 386 <div className="user-header"> 377 387 <h1>{displayName}</h1> 378 388 <h2>@{handle}</h2> 379 389 </div> 390 + 391 + {/* Activity Chart */} 392 + <ActivityChart 393 + records={allRecordsForChart} 394 + collections={collections} 395 + /> 380 396 381 397 <div className="feed-controls"> 382 398 <div className="filter-container">