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 if (dataPage.length == 0) { return }
160
161 let last = dataPage.at(-1);
162 let lastTimestamp = last.reason ? last.reason.indexedAt : last.post.record.createdAt;
163 let lastDate = Date.parse(lastTimestamp);
164
165 let daysBack = (startTime - lastDate) / 86400 / 1000;
166 this.progressBar.value = daysBack;
167 }
168
169 /** @param {json} a, @param {json} b, @returns {number} */
170
171 sortUserRows(a, b) {
172 let asum = a.own + a.reposts;
173 let bsum = b.own + b.reposts;
174
175 if (asum < bsum) {
176 return 1;
177 } else if (asum > bsum) {
178 return -1;
179 } else {
180 return 0;
181 }
182 }
183
184 /**
185 * @param {json[]} items
186 * @param {number} startTime
187 * @param {number} requestedDays
188 * @param {{ showTotal?: boolean, showPercentages?: boolean, showReposts?: boolean }} [options]
189 */
190
191 updateResultsTable(items, startTime, requestedDays, options = {}) {
192 let users = {};
193 let total = 0;
194 let allReposts = 0;
195 let allNormalPosts = 0;
196
197 let last = items.at(-1);
198 let lastTimestamp = last.reason ? last.reason.indexedAt : last.post.record.createdAt;
199 let lastDate = Date.parse(lastTimestamp);
200 let daysBack = (startTime - lastDate) / 86400 / 1000;
201
202 for (let item of items) {
203 if (item.reply) { continue; }
204
205 let user = item.reason ? item.reason.by : item.post.author;
206 let handle = user.handle;
207 users[handle] = users[handle] ?? { handle: handle, own: 0, reposts: 0, avatar: user.avatar };
208 total += 1;
209
210 if (item.reason) {
211 users[handle].reposts += 1;
212 allReposts += 1;
213 } else {
214 users[handle].own += 1;
215 allNormalPosts += 1;
216 }
217 }
218
219 let thead = $(this.table.querySelector('thead'));
220 let headRow = $tag('tr');
221
222 if (options.showReposts !== false) {
223 headRow.append(
224 $tag('th', { text: '#' }),
225 $tag('th', { text: 'Handle' }),
226 $tag('th', { text: 'All posts /d' }),
227 $tag('th', { text: 'Own posts /d' }),
228 $tag('th', { text: 'Reposts /d' })
229 );
230 } else {
231 headRow.append(
232 $tag('th', { text: '#' }),
233 $tag('th', { text: 'Handle' }),
234 $tag('th', { text: 'Posts /d' }),
235 );
236 }
237
238 if (options.showPercentages !== false) {
239 headRow.append($tag('th', { text: '% of all' }));
240 }
241
242 thead.append(headRow);
243
244 let tbody = $(this.table.querySelector('tbody'));
245
246 if (options.showTotal !== false) {
247 let tr = $tag('tr.total');
248
249 tr.append(
250 $tag('td.no', { text: '' }),
251 $tag('td.handle', { text: 'Total:' }),
252
253 (options.showReposts !== false) ?
254 $tag('td', { text: (total / daysBack).toFixed(1) }) : '',
255
256 $tag('td', { text: (allNormalPosts / daysBack).toFixed(1) }),
257
258 (options.showReposts !== false) ?
259 $tag('td', { text: (allReposts / daysBack).toFixed(1) }) : ''
260 );
261
262 if (options.showPercentages !== false) {
263 tr.append($tag('td.percent', { text: '' }));
264 }
265
266 tbody.append(tr);
267 }
268
269 let sorted = Object.values(users).sort(this.sortUserRows);
270
271 for (let i = 0; i < sorted.length; i++) {
272 let user = sorted[i];
273 let tr = $tag('tr');
274
275 tr.append(
276 $tag('td.no', { text: i + 1 }),
277 $tag('td.handle', {
278 html: `<img class="avatar" src="${user.avatar}"> ` +
279 `<a href="https://bsky.app/profile/${user.handle}" target="_blank">${user.handle}</a>`
280 }),
281
282 (options.showReposts !== false) ?
283 $tag('td', { text: ((user.own + user.reposts) / daysBack).toFixed(1) }) : '',
284
285 $tag('td', { text: user.own > 0 ? (user.own / daysBack).toFixed(1) : '–' }),
286
287 (options.showReposts !== false) ?
288 $tag('td', { text: user.reposts > 0 ? (user.reposts / daysBack).toFixed(1) : '–' }) : ''
289 );
290
291 if (options.showPercentages !== false) {
292 tr.append($tag('td.percent', { text: ((user.own + user.reposts) * 100 / total).toFixed(1) + '%' }));
293 }
294
295 tbody.append(tr);
296 }
297
298 if (Math.ceil(daysBack) < requestedDays) {
299 let scanInfo = $(this.pageElement.querySelector('.scan-info'));
300 scanInfo.innerText = `🕓 Showing data from ${Math.round(daysBack)} days (your timeline only goes that far):`;
301 scanInfo.style.display = 'block';
302 }
303
304 this.table.style.display = 'table';
305 this.submitButton.value = 'Start scan';
306 this.progressBar.style.display = 'none';
307 this.scanStartTime = undefined;
308 }
309
310 stopScan() {
311 this.submitButton.value = 'Start scan';
312 this.scanStartTime = undefined;
313 this.progressBar.style.display = 'none';
314 }
315}