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 13 this.rangeInput = $(this.pageElement.querySelector('input[type="range"]'), HTMLInputElement); 14 this.submitButton = $(this.pageElement.querySelector('input[type="submit"]'), HTMLInputElement); 15 this.progressBar = $(this.pageElement.querySelector('input[type=submit] + progress'), HTMLProgressElement); 16 this.table = $(this.pageElement.querySelector('table.scan-result')); 17 18 this.setupEvents(); 19 } 20 21 setupEvents() { 22 $(this.pageElement.querySelector('form')).addEventListener('submit', (e) => { 23 e.preventDefault(); 24 25 if (!this.scanStartTime) { 26 this.scanPostingStats(); 27 } else { 28 this.stopScan(); 29 } 30 }); 31 32 this.rangeInput.addEventListener('input', (e) => { 33 let days = parseInt(this.rangeInput.value, 10); 34 this.configurePostingStats({ days }); 35 }); 36 } 37 38 show() { 39 this.pageElement.style.display = 'block'; 40 } 41 42 /** @returns {number} */ 43 44 selectedDaysRange() { 45 return parseInt(this.rangeInput.value, 10); 46 } 47 48 /** @param {{ days: number }} args */ 49 50 configurePostingStats(args) { 51 if (args.days) { 52 let label = $(this.pageElement.querySelector('input[type=range] + label')); 53 label.innerText = (args.days == 1) ? '1 day' : `${args.days} days`; 54 } 55 } 56 57 /** @returns {Promise<void>} */ 58 59 async scanPostingStats() { 60 this.submitButton.value = 'Cancel'; 61 62 let requestedDays = this.selectedDaysRange(); 63 64 this.progressBar.max = requestedDays; 65 this.progressBar.value = 0; 66 this.progressBar.style.display = 'inline'; 67 68 this.table.style.display = 'none'; 69 70 let tbody = $(this.table.querySelector('tbody')); 71 tbody.innerHTML = ''; 72 73 let startTime = new Date().getTime(); 74 this.scanStartTime = startTime; 75 76 let scanInfo = $(this.pageElement.querySelector('.scan-info')); 77 scanInfo.style.display = 'none'; 78 79 let items = await accountAPI.loadTimeline(requestedDays, { 80 onPageLoad: (data) => { 81 if (this.scanStartTime != startTime) { 82 return { cancel: true }; 83 } 84 85 this.updateProgress(data, startTime); 86 } 87 }); 88 89 if (this.scanStartTime != startTime) { 90 return; 91 } 92 93 this.updateResultsTable(items, startTime, requestedDays); 94 } 95 96 /** @param {json[]} dataPage, @param {number} startTime */ 97 98 updateProgress(dataPage, startTime) { 99 if (dataPage.length == 0) { return } 100 101 let last = dataPage.at(-1); 102 let lastTimestamp = last.reason ? last.reason.indexedAt : last.post.record.createdAt; 103 let lastDate = Date.parse(lastTimestamp); 104 105 let daysBack = (startTime - lastDate) / 86400 / 1000; 106 this.progressBar.value = daysBack; 107 } 108 109 /** @param {json} a, @param {json} b, @returns {number} */ 110 111 sortUserRows(a, b) { 112 let asum = a.own + a.reposts; 113 let bsum = b.own + b.reposts; 114 115 if (asum < bsum) { 116 return 1; 117 } else if (asum > bsum) { 118 return -1; 119 } else { 120 return 0; 121 } 122 } 123 124 /** @param {json[]} items, @param {number} startTime, @param {number} requestedDays */ 125 126 updateResultsTable(items, startTime, requestedDays) { 127 let users = {}; 128 let total = 0; 129 let allReposts = 0; 130 let allNormalPosts = 0; 131 132 let last = items.at(-1); 133 let lastTimestamp = last.reason ? last.reason.indexedAt : last.post.record.createdAt; 134 let lastDate = Date.parse(lastTimestamp); 135 let daysBack = (startTime - lastDate) / 86400 / 1000; 136 137 for (let item of items) { 138 if (item.reply) { continue; } 139 140 let user = item.reason ? item.reason.by : item.post.author; 141 let handle = user.handle; 142 users[handle] = users[handle] ?? { handle: handle, own: 0, reposts: 0, avatar: user.avatar }; 143 total += 1; 144 145 if (item.reason) { 146 users[handle].reposts += 1; 147 allReposts += 1; 148 } else { 149 users[handle].own += 1; 150 allNormalPosts += 1; 151 } 152 } 153 154 let tbody = $(this.table.querySelector('tbody')); 155 let tr = $tag('tr.total'); 156 157 tr.append( 158 $tag('td.no', { text: '' }), 159 $tag('td.handle', { text: 'Total:' }), 160 $tag('td', { text: (total / daysBack).toFixed(1) }), 161 $tag('td', { text: (allNormalPosts / daysBack).toFixed(1) }), 162 $tag('td', { text: (allReposts / daysBack).toFixed(1) }), 163 $tag('td.percent', { text: '' }) 164 ); 165 166 tbody.append(tr); 167 168 let sorted = Object.values(users).sort(this.sortUserRows); 169 170 for (let i = 0; i < sorted.length; i++) { 171 let user = sorted[i]; 172 let tr = $tag('tr'); 173 174 tr.append( 175 $tag('td.no', { text: i + 1 }), 176 $tag('td.handle', { 177 html: `<img class="avatar" src="${user.avatar}"> ` + 178 `<a href="https://bsky.app/profile/${user.handle}" target="_blank">${user.handle}</a>` 179 }), 180 $tag('td', { text: ((user.own + user.reposts) / daysBack).toFixed(1) }), 181 $tag('td', { text: user.own > 0 ? (user.own / daysBack).toFixed(1) : '–' }), 182 $tag('td', { text: user.reposts > 0 ? (user.reposts / daysBack).toFixed(1) : '–' }), 183 $tag('td.percent', { text: ((user.own + user.reposts) * 100 / total).toFixed(1) + '%' }) 184 ); 185 186 tbody.append(tr); 187 } 188 189 if (Math.ceil(daysBack) < requestedDays) { 190 let scanInfo = $(this.pageElement.querySelector('.scan-info')); 191 scanInfo.innerText = `🕓 Showing data from ${Math.round(daysBack)} days (your timeline only goes that far):`; 192 scanInfo.style.display = 'block'; 193 } 194 195 this.table.style.display = 'table'; 196 this.submitButton.value = 'Start scan'; 197 this.progressBar.style.display = 'none'; 198 this.scanStartTime = undefined; 199 } 200 201 stopScan() { 202 this.submitButton.value = 'Start scan'; 203 this.scanStartTime = undefined; 204 this.progressBar.style.display = 'none'; 205 } 206}