···
13
13
justify-content: center;
14
14
margin-bottom: 15px;
15
15
flex-wrap: wrap;
16
16
-
gap: 10px;
16
16
+
gap: 8px;
17
17
+
max-width: 90%;
18
18
+
margin-left: auto;
19
19
+
margin-right: auto;
17
20
}
18
21
19
22
.time-period-button {
···
21
24
color: var(--text);
22
25
border: 1px solid var(--card-border);
23
26
border-radius: 4px;
24
24
-
padding: 8px 12px;
25
25
-
font-size: 0.9rem;
27
27
+
padding: 8px 10px;
28
28
+
font-size: 0.85rem;
26
29
cursor: pointer;
27
30
transition: all 0.2s ease;
31
31
+
flex: 1;
32
32
+
min-width: 90px;
33
33
+
white-space: nowrap;
28
34
}
29
35
30
36
.time-period-button:hover {
···
28
28
datasets: []
29
29
});
30
30
31
31
+
// App color scheme
32
32
+
const bskyColor = 'rgba(0, 133, 255, 0.7)'; // Lighter blue for Bluesky
33
33
+
const bskyBorderColor = 'rgba(0, 133, 255, 1)';
34
34
+
const atprotoColor = 'rgba(0, 51, 102, 0.8)'; // Darker blue for ATProto
35
35
+
const atprotoBorderColor = 'rgba(0, 51, 102, 1)';
36
36
+
31
37
useEffect(() => {
32
38
// Only generate chart data if we have records
33
39
if (records && records.length > 0) {
···
41
47
const currentDate = new Date();
42
48
let startDate;
43
49
let dateFormat;
50
50
+
let bucketSize;
51
51
+
let timeFormat;
44
52
45
53
switch (period) {
54
54
+
case '24hours':
55
55
+
startDate = new Date(currentDate);
56
56
+
startDate.setHours(currentDate.getHours() - 24);
57
57
+
dateFormat = { hour: '2-digit' }; // "05 PM"
58
58
+
bucketSize = 'hour';
59
59
+
timeFormat = true;
60
60
+
break;
46
61
case '7days':
47
62
startDate = new Date(currentDate);
48
63
startDate.setDate(currentDate.getDate() - 7);
49
64
dateFormat = { month: 'short', day: 'numeric' }; // "Jan 1"
65
65
+
bucketSize = 'day';
66
66
+
timeFormat = false;
50
67
break;
51
68
case '30days':
52
69
startDate = new Date(currentDate);
53
70
startDate.setDate(currentDate.getDate() - 30);
54
71
dateFormat = { month: 'short', day: 'numeric' }; // "Jan 1"
72
72
+
bucketSize = 'day';
73
73
+
timeFormat = false;
55
74
break;
56
75
case '90days':
57
76
startDate = new Date(currentDate);
58
77
startDate.setDate(currentDate.getDate() - 90);
59
59
-
dateFormat = { month: 'short' }; // "January"
78
78
+
// For 90 days, group by week instead of day to make it more readable
79
79
+
dateFormat = { month: 'short', day: 'numeric' };
80
80
+
bucketSize = 'week';
81
81
+
timeFormat = false;
60
82
break;
61
83
default:
62
84
startDate = new Date(currentDate);
63
85
startDate.setDate(currentDate.getDate() - 7);
64
86
dateFormat = { month: 'short', day: 'numeric' }; // "Jan 1"
87
87
+
bucketSize = 'day';
88
88
+
timeFormat = false;
65
89
}
66
90
67
91
// Create date buckets
68
92
const dateBuckets = {};
69
93
const labels = [];
70
94
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);
95
95
+
// For 90 days with weekly buckets, calculate week numbers
96
96
+
if (bucketSize === 'week') {
97
97
+
// Create weekly buckets
98
98
+
let currentWeekStart = new Date(startDate);
99
99
+
// Adjust to start on Sunday or Monday (Sunday = 0, Monday = 1)
100
100
+
const dayOfWeek = currentWeekStart.getDay();
101
101
+
if (dayOfWeek !== 0) { // If not Sunday
102
102
+
// Adjust date to previous Sunday
103
103
+
currentWeekStart.setDate(currentWeekStart.getDate() - dayOfWeek);
104
104
+
}
77
105
78
78
-
dateBuckets[dateKey] = {
79
79
-
date: formattedDate,
80
80
-
total: 0,
81
81
-
bskyRecords: 0,
82
82
-
nonBskyRecords: 0
83
83
-
};
106
106
+
while (currentWeekStart <= currentDate) {
107
107
+
const weekEndDate = new Date(currentWeekStart);
108
108
+
weekEndDate.setDate(weekEndDate.getDate() + 6); // End date is 6 days after start (for a full week)
109
109
+
110
110
+
const bucketKey = currentWeekStart.toISOString().split('T')[0];
111
111
+
const startLabel = currentWeekStart.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
112
112
+
const endLabel = weekEndDate.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
113
113
+
const weekLabel = `${startLabel} - ${endLabel}`;
114
114
+
115
115
+
dateBuckets[bucketKey] = {
116
116
+
label: weekLabel,
117
117
+
start: new Date(currentWeekStart), // Store start and end dates for filtering
118
118
+
end: new Date(weekEndDate),
119
119
+
bskyRecords: 0,
120
120
+
atprotoRecords: 0
121
121
+
};
122
122
+
123
123
+
labels.push(weekLabel);
124
124
+
125
125
+
// Move to next week
126
126
+
currentWeekStart.setDate(currentWeekStart.getDate() + 7);
127
127
+
}
128
128
+
}
129
129
+
else if (bucketSize === 'hour') {
130
130
+
// Create hourly buckets for 24-hour view
131
131
+
let currentHour = new Date(startDate);
132
132
+
currentHour.setMinutes(0, 0, 0); // Start at the beginning of the hour
84
133
85
85
-
labels.push(formattedDate);
134
134
+
while (currentHour <= currentDate) {
135
135
+
const hourKey = currentHour.toISOString();
136
136
+
let hourLabel;
137
137
+
138
138
+
if (timeFormat) {
139
139
+
hourLabel = currentHour.toLocaleTimeString('en-US', { hour: '2-digit' });
140
140
+
} else {
141
141
+
hourLabel = currentHour.toLocaleDateString('en-US', dateFormat);
142
142
+
}
143
143
+
144
144
+
dateBuckets[hourKey] = {
145
145
+
label: hourLabel,
146
146
+
timestamp: new Date(currentHour),
147
147
+
bskyRecords: 0,
148
148
+
atprotoRecords: 0
149
149
+
};
150
150
+
151
151
+
labels.push(hourLabel);
152
152
+
153
153
+
// Move to next hour
154
154
+
currentHour.setHours(currentHour.getHours() + 1);
155
155
+
}
156
156
+
}
157
157
+
else {
158
158
+
// Create daily buckets for 7-day and 30-day views
159
159
+
let currentDay = new Date(startDate);
160
160
+
currentDay.setHours(0, 0, 0, 0); // Start at the beginning of the day
86
161
87
87
-
// Move to next day
88
88
-
currentBucket.setDate(currentBucket.getDate() + 1);
162
162
+
while (currentDay <= currentDate) {
163
163
+
const dayKey = currentDay.toISOString().split('T')[0]; // YYYY-MM-DD
164
164
+
const dayLabel = currentDay.toLocaleDateString('en-US', dateFormat);
165
165
+
166
166
+
dateBuckets[dayKey] = {
167
167
+
label: dayLabel,
168
168
+
date: new Date(currentDay),
169
169
+
bskyRecords: 0,
170
170
+
atprotoRecords: 0
171
171
+
};
172
172
+
173
173
+
labels.push(dayLabel);
174
174
+
175
175
+
// Move to next day
176
176
+
currentDay.setDate(currentDay.getDate() + 1);
177
177
+
}
89
178
}
90
179
91
91
-
// Count records for each date
180
180
+
// Count records for each bucket
92
181
allRecords.forEach(record => {
93
182
// Use either content timestamp or rkey timestamp, prioritizing content
94
183
const timestamp = record.contentTimestamp || record.rkeyTimestamp;
95
184
if (!timestamp) return;
96
185
97
186
const recordDate = new Date(timestamp);
98
98
-
const dateKey = recordDate.toISOString().split('T')[0]; // YYYY-MM-DD
187
187
+
188
188
+
// Find the matching bucket based on the time period type
189
189
+
let matchingBucketKey = null;
190
190
+
191
191
+
if (bucketSize === 'week') {
192
192
+
// For weekly buckets, find the week that contains this record
193
193
+
for (const bucketKey in dateBuckets) {
194
194
+
const bucket = dateBuckets[bucketKey];
195
195
+
if (recordDate >= bucket.start && recordDate <= bucket.end) {
196
196
+
matchingBucketKey = bucketKey;
197
197
+
break;
198
198
+
}
199
199
+
}
200
200
+
}
201
201
+
else if (bucketSize === 'hour') {
202
202
+
// For hourly buckets, find the hour
203
203
+
const hourStart = new Date(recordDate);
204
204
+
hourStart.setMinutes(0, 0, 0);
205
205
+
matchingBucketKey = hourStart.toISOString();
206
206
+
}
207
207
+
else {
208
208
+
// For daily buckets, use the date key
209
209
+
matchingBucketKey = recordDate.toISOString().split('T')[0]; // YYYY-MM-DD
210
210
+
}
99
211
100
212
// 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
213
213
+
if (matchingBucketKey && dateBuckets[matchingBucketKey]) {
214
214
+
// Track Bluesky vs non-Bluesky records
105
215
if (record.collection.startsWith('app.bsky.')) {
106
106
-
dateBuckets[dateKey].bskyRecords += 1;
216
216
+
dateBuckets[matchingBucketKey].bskyRecords += 1;
107
217
} else {
108
108
-
dateBuckets[dateKey].nonBskyRecords += 1;
218
218
+
dateBuckets[matchingBucketKey].atprotoRecords += 1;
109
219
}
110
220
}
111
221
});
112
222
113
223
// Format data for Chart.js
114
114
-
const totalData = [];
115
224
const bskyData = [];
116
116
-
const nonBskyData = [];
225
225
+
const atprotoData = [];
117
226
118
227
// Extract data in the same order as labels
119
228
labels.forEach(label => {
120
120
-
// Find the matching bucket by formatted date
121
121
-
const bucket = Object.values(dateBuckets).find(b => b.date === label);
229
229
+
// Find the matching bucket by label
230
230
+
const bucket = Object.values(dateBuckets).find(b => b.label === label);
122
231
123
232
if (bucket) {
124
124
-
totalData.push(bucket.total);
125
233
bskyData.push(bucket.bskyRecords);
126
126
-
nonBskyData.push(bucket.nonBskyRecords);
234
234
+
atprotoData.push(bucket.atprotoRecords);
127
235
} else {
128
236
// Fallback (shouldn't happen)
129
129
-
totalData.push(0);
130
237
bskyData.push(0);
131
131
-
nonBskyData.push(0);
238
238
+
atprotoData.push(0);
132
239
}
133
240
});
134
241
135
135
-
// Set the chart data
242
242
+
// Set the chart data for a stacked chart
136
243
setChartData({
137
244
labels,
138
245
datasets: [
139
246
{
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
247
label: 'Bluesky Records',
148
248
data: bskyData,
149
149
-
backgroundColor: 'rgba(75, 192, 192, 0.6)',
150
150
-
borderColor: 'rgba(75, 192, 192, 1)',
249
249
+
backgroundColor: bskyColor,
250
250
+
borderColor: bskyBorderColor,
151
251
borderWidth: 1
152
252
},
153
253
{
154
254
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)',
255
255
+
data: atprotoData,
256
256
+
backgroundColor: atprotoColor,
257
257
+
borderColor: atprotoBorderColor,
158
258
borderWidth: 1
159
259
}
160
260
]
161
261
});
162
262
};
163
263
164
164
-
// Chart options
264
264
+
// Chart options for stacked bar
165
265
const options = {
166
266
responsive: true,
167
267
maintainAspectRatio: false,
···
171
271
},
172
272
title: {
173
273
display: true,
174
174
-
text: 'ATProto Activity by Date'
274
274
+
text: 'ATProto Activity'
175
275
},
176
276
tooltip: {
177
277
callbacks: {
···
182
282
const label = context.dataset.label || '';
183
283
const value = context.raw || 0;
184
284
return `${label}: ${value} record${value !== 1 ? 's' : ''}`;
285
285
+
},
286
286
+
// Add footer for total
287
287
+
footer: (tooltipItems) => {
288
288
+
let sum = 0;
289
289
+
tooltipItems.forEach(tooltipItem => {
290
290
+
sum += tooltipItem.parsed.y;
291
291
+
});
292
292
+
return `Total: ${sum} record${sum !== 1 ? 's' : ''}`;
185
293
}
186
294
}
187
295
}
188
296
},
189
297
scales: {
190
298
x: {
299
299
+
stacked: true,
191
300
title: {
192
301
display: true,
193
193
-
text: 'Date'
302
302
+
text: timePeriod === '24hours' ? 'Hour' : 'Date'
194
303
}
195
304
},
196
305
y: {
306
306
+
stacked: true,
197
307
beginAtZero: true,
198
308
title: {
199
309
display: true,
···
217
327
return (
218
328
<div className="activity-chart-container">
219
329
<div className="time-period-selector">
330
330
+
<button
331
331
+
className={`time-period-button ${timePeriod === '24hours' ? 'active' : ''}`}
332
332
+
onClick={() => setTimePeriod('24hours')}
333
333
+
>
334
334
+
Last 24 Hours
335
335
+
</button>
220
336
<button
221
337
className={`time-period-button ${timePeriod === '7days' ? 'active' : ''}`}
222
338
onClick={() => setTimePeriod('7days')}