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