Thread viewer for Bluesky
1/**
2 * Manages the Posting Stats page.
3 */
4
5class PostingStatsPage {
6
7 /** @type {number | undefined} */
8 scanStartTime;
9
10 constructor() {
11 this.pageElement = $id('posting_stats_page');
12 this.form = $(this.pageElement.querySelector('form'), HTMLFormElement);
13
14 this.rangeInput = $(this.pageElement.querySelector('input[type="range"]'), HTMLInputElement);
15 this.submitButton = $(this.pageElement.querySelector('input[type="submit"]'), HTMLInputElement);
16 this.progressBar = $(this.pageElement.querySelector('input[type=submit] + progress'), HTMLProgressElement);
17 this.table = $(this.pageElement.querySelector('table.scan-result'));
18
19 this.setupEvents();
20 }
21
22 setupEvents() {
23 $(this.pageElement.querySelector('form')).addEventListener('submit', (e) => {
24 e.preventDefault();
25
26 if (!this.scanStartTime) {
27 this.scanPostingStats();
28 } else {
29 this.stopScan();
30 }
31 });
32
33 this.rangeInput.addEventListener('input', (e) => {
34 let days = parseInt(this.rangeInput.value, 10);
35 this.configurePostingStats({ days });
36 });
37
38 this.pageElement.querySelectorAll('input[type="radio"]').forEach(r => {
39 r.addEventListener('click', (e) => {
40 let value = $(r, HTMLInputElement).value;
41
42 $(this.pageElement.querySelector('.list-choice')).style.display = (value == 'list') ? 'block' : 'none';
43
44 this.table.style.display = 'none';
45 });
46 });
47 }
48
49 show() {
50 this.pageElement.style.display = 'block';
51 this.fetchLists();
52 }
53
54 /** @returns {number} */
55
56 selectedDaysRange() {
57 return parseInt(this.rangeInput.value, 10);
58 }
59
60 /** @returns {Promise<void>} */
61
62 async fetchLists() {
63 let select = $(this.pageElement.querySelector('.list-choice select'));
64 let lists = await accountAPI.loadUserLists();
65
66 let sorted = lists.sort((a, b) => {
67 let aName = a.name.toLocaleLowerCase();
68 let bName = b.name.toLocaleLowerCase();
69
70 return aName.localeCompare(bName);
71 });
72
73 for (let list of lists) {
74 let opt = $tag('option', { value: list.uri, text: list.name + ' ' });
75 select.append(opt);
76 }
77 }
78
79 /** @param {{ days: number }} args */
80
81 configurePostingStats(args) {
82 if (args.days) {
83 let label = $(this.pageElement.querySelector('input[type=range] + label'));
84 label.innerText = (args.days == 1) ? '1 day' : `${args.days} days`;
85 }
86 }
87
88 /** @returns {Promise<void>} */
89
90 async scanPostingStats() {
91 this.submitButton.value = 'Cancel';
92
93 let requestedDays = this.selectedDaysRange();
94
95 this.progressBar.max = requestedDays;
96 this.progressBar.value = 0;
97 this.progressBar.style.display = 'inline';
98
99 this.table.style.display = 'none';
100
101 let tbody = $(this.table.querySelector('tbody'));
102 tbody.innerHTML = '';
103
104 let thead = $(this.table.querySelector('thead'));
105 thead.innerHTML = '';
106
107 let startTime = new Date().getTime();
108 this.scanStartTime = startTime;
109
110 let scanInfo = $(this.pageElement.querySelector('.scan-info'));
111 scanInfo.style.display = 'none';
112
113 /** @type {FetchAllOnPageLoad} */
114 let onPageLoad = (data) => {
115 if (this.scanStartTime != startTime) {
116 return { cancel: true };
117 }
118
119 this.updateProgress(data, startTime);
120 };
121
122 let scanType = this.form.elements['scan_type'].value;
123
124 if (scanType == 'home') {
125 let items = await accountAPI.loadHomeTimeline(requestedDays, { onPageLoad });
126
127 if (this.scanStartTime != startTime) {
128 return;
129 }
130
131 this.updateResultsTable(items, startTime, requestedDays);
132 } else if (scanType == 'list') {
133 let select = $(this.pageElement.querySelector('.list-choice select'), HTMLSelectElement);
134 let list = select.value;
135 let items = await accountAPI.loadListTimeline(list, requestedDays, { onPageLoad });
136
137 if (this.scanStartTime != startTime) {
138 return;
139 }
140
141 this.updateResultsTable(items, startTime, requestedDays, { showReposts: false });
142 } else {
143 let items = await accountAPI.loadUserTimeline(accountAPI.user.did, requestedDays, {
144 filter: 'posts_no_replies',
145 onPageLoad: onPageLoad
146 });
147
148 if (this.scanStartTime != startTime) {
149 return;
150 }
151
152 this.updateResultsTable(items, startTime, requestedDays, { showTotal: false, showPercentages: false });
153 }
154 }
155
156 /** @param {json[]} dataPage, @param {number} startTime */
157
158 updateProgress(dataPage, startTime) {
159 let last = dataPage.at(-1);
160
161 if (!last) { return }
162
163 let lastTimestamp = last.reason ? last.reason.indexedAt : last.post.record.createdAt;
164 let lastDate = Date.parse(lastTimestamp);
165 let daysBack = (startTime - lastDate) / 86400 / 1000;
166
167 this.progressBar.value = daysBack;
168 }
169
170 /** @param {json} a, @param {json} b, @returns {number} */
171
172 sortUserRows(a, b) {
173 let asum = a.own + a.reposts;
174 let bsum = b.own + b.reposts;
175
176 if (asum < bsum) {
177 return 1;
178 } else if (asum > bsum) {
179 return -1;
180 } else {
181 return 0;
182 }
183 }
184
185 /**
186 * @param {json[]} items
187 * @param {number} startTime
188 * @param {number} requestedDays
189 * @param {{ showTotal?: boolean, showPercentages?: boolean, showReposts?: boolean }} [options]
190 */
191
192 updateResultsTable(items, startTime, requestedDays, options = {}) {
193 let users = {};
194 let total = 0;
195 let allReposts = 0;
196 let allNormalPosts = 0;
197
198 let last = items.at(-1);
199
200 if (!last) {
201 this.stopScan();
202 return;
203 }
204
205 let lastTimestamp = last.reason ? last.reason.indexedAt : last.post.record.createdAt;
206 let lastDate = Date.parse(lastTimestamp);
207 let daysBack = (startTime - lastDate) / 86400 / 1000;
208
209 for (let item of items) {
210 if (item.reply) { continue; }
211
212 let user = item.reason ? item.reason.by : item.post.author;
213 let handle = user.handle;
214 users[handle] = users[handle] ?? { handle: handle, own: 0, reposts: 0, avatar: user.avatar };
215 total += 1;
216
217 if (item.reason) {
218 users[handle].reposts += 1;
219 allReposts += 1;
220 } else {
221 users[handle].own += 1;
222 allNormalPosts += 1;
223 }
224 }
225
226 let thead = $(this.table.querySelector('thead'));
227 let headRow = $tag('tr');
228
229 if (options.showReposts !== false) {
230 headRow.append(
231 $tag('th', { text: '#' }),
232 $tag('th', { text: 'Handle' }),
233 $tag('th', { text: 'All posts /d' }),
234 $tag('th', { text: 'Own posts /d' }),
235 $tag('th', { text: 'Reposts /d' })
236 );
237 } else {
238 headRow.append(
239 $tag('th', { text: '#' }),
240 $tag('th', { text: 'Handle' }),
241 $tag('th', { text: 'Posts /d' }),
242 );
243 }
244
245 if (options.showPercentages !== false) {
246 headRow.append($tag('th', { text: '% of all' }));
247 }
248
249 thead.append(headRow);
250
251 let tbody = $(this.table.querySelector('tbody'));
252
253 if (options.showTotal !== false) {
254 let tr = $tag('tr.total');
255
256 tr.append(
257 $tag('td.no', { text: '' }),
258 $tag('td.handle', { text: 'Total:' }),
259
260 (options.showReposts !== false) ?
261 $tag('td', { text: (total / daysBack).toFixed(1) }) : '',
262
263 $tag('td', { text: (allNormalPosts / daysBack).toFixed(1) }),
264
265 (options.showReposts !== false) ?
266 $tag('td', { text: (allReposts / daysBack).toFixed(1) }) : ''
267 );
268
269 if (options.showPercentages !== false) {
270 tr.append($tag('td.percent', { text: '' }));
271 }
272
273 tbody.append(tr);
274 }
275
276 let sorted = Object.values(users).sort(this.sortUserRows);
277
278 for (let i = 0; i < sorted.length; i++) {
279 let user = sorted[i];
280 let tr = $tag('tr');
281
282 tr.append(
283 $tag('td.no', { text: i + 1 }),
284 $tag('td.handle', {
285 html: `<img class="avatar" src="${user.avatar}"> ` +
286 `<a href="https://bsky.app/profile/${user.handle}" target="_blank">${user.handle}</a>`
287 }),
288
289 (options.showReposts !== false) ?
290 $tag('td', { text: ((user.own + user.reposts) / daysBack).toFixed(1) }) : '',
291
292 $tag('td', { text: user.own > 0 ? (user.own / daysBack).toFixed(1) : '–' }),
293
294 (options.showReposts !== false) ?
295 $tag('td', { text: user.reposts > 0 ? (user.reposts / daysBack).toFixed(1) : '–' }) : ''
296 );
297
298 if (options.showPercentages !== false) {
299 tr.append($tag('td.percent', { text: ((user.own + user.reposts) * 100 / total).toFixed(1) + '%' }));
300 }
301
302 tbody.append(tr);
303 }
304
305 if (Math.ceil(daysBack) < requestedDays) {
306 let scanInfo = $(this.pageElement.querySelector('.scan-info'));
307 scanInfo.innerText = `🕓 Showing data from ${Math.round(daysBack)} days (your timeline only goes that far):`;
308 scanInfo.style.display = 'block';
309 }
310
311 this.table.style.display = 'table';
312 this.submitButton.value = 'Start scan';
313 this.progressBar.style.display = 'none';
314 this.scanStartTime = undefined;
315 }
316
317 stopScan() {
318 this.submitButton.value = 'Start scan';
319 this.scanStartTime = undefined;
320 this.progressBar.style.display = 'none';
321 }
322}