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[dataPage.length - 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[items.length - 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}