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}