Files: 14ac2ad367289482ef190e031665686d5734bf6a / app / page / statsShow.js
11053 bytesRaw
1 | const nest = require('depnest') |
2 | const { h, resolve, when, Value, Struct, Array: MutantArray, Dict, onceTrue, map, computed, throttle, watchAll } = require('mutant') |
3 | const pull = require('pull-stream') |
4 | const marksum = require('markdown-summary') |
5 | const Chart = require('chart.js') |
6 | const groupBy = require('lodash/groupBy') |
7 | const flatMap = require('lodash/flatMap') |
8 | const get = require('lodash/get') |
9 | |
10 | exports.gives = nest('app.page.statsShow') |
11 | |
12 | exports.needs = nest({ |
13 | 'sbot.obs.connection': 'first', |
14 | 'history.sync.push': 'first', |
15 | 'message.html.markdown': 'first' |
16 | }) |
17 | |
18 | const COMMENTS = 'comments' |
19 | const LIKES = 'likes' |
20 | const SHARES = 'shares' |
21 | const DAY = 24 * 60 * 60 * 1000 |
22 | |
23 | const getRoot = { |
24 | [COMMENTS]: (msg) => get(msg, 'value.content.root'), |
25 | [LIKES]: (msg) => get(msg, 'value.content.vote.link') |
26 | } |
27 | |
28 | exports.create = (api) => { |
29 | return nest('app.page.statsShow', statsShow) |
30 | |
31 | function statsShow (location) { |
32 | var store = Struct({ |
33 | blogs: MutantArray([]), |
34 | comments: Dict(), |
35 | likes: Dict(), |
36 | shares: Dict() |
37 | }) |
38 | onceTrue(api.sbot.obs.connection, server => fetchBlogData({ server, store })) |
39 | |
40 | var foci = Struct({ |
41 | [COMMENTS]: computed([throttle(store.comments, 1000)], (msgs) => { |
42 | return flatMap(msgs, (val, key) => val) |
43 | }), |
44 | [LIKES]: computed([throttle(store.likes, 1000)], (msgs) => { |
45 | return flatMap(msgs, (val, key) => val) |
46 | }), |
47 | [SHARES]: [] |
48 | }) |
49 | |
50 | var howFarBack = Value(0) |
51 | // stats show a moving window of 30 days |
52 | var context = Struct({ |
53 | focus: Value(COMMENTS), |
54 | blog: Value(), |
55 | range: computed([howFarBack], howFarBack => { |
56 | const now = Date.now() |
57 | const endOfDay = (Math.floor(now / DAY) + 1) * DAY |
58 | |
59 | return { |
60 | upper: endOfDay - howFarBack * 30 * DAY, |
61 | lower: endOfDay - (howFarBack + 1) * 30 * DAY |
62 | } |
63 | }) |
64 | }) |
65 | |
66 | function totalOnscreenData (focus) { |
67 | return computed([foci[focus], context], (msgs, context) => { |
68 | // NOTE this filter logic is repeated in chartData |
69 | return msgs |
70 | .filter(msg => { |
71 | if (!context.blog) return true |
72 | // if context.blog is set, filter down to only msgs about that blog |
73 | return getRoot[focus](msg) === context.blog |
74 | }) |
75 | .filter(msg => { |
76 | // don't count unlikes |
77 | if (focus === LIKES) return get(msg, 'value.content.vote.value') > 0 |
78 | else return true |
79 | }) |
80 | .filter(msg => { |
81 | const ts = msg.value.timestamp |
82 | return ts > context.range.lower && ts <= context.range.upper |
83 | }) |
84 | .length |
85 | }) |
86 | } |
87 | |
88 | const canvas = h('canvas', { height: 200, width: 600, style: { height: '200px', width: '600px' } }) |
89 | |
90 | const page = h('Page -statsShow', [ |
91 | h('Scroller.content', [ |
92 | h('div.content', [ |
93 | h('h1', 'Stats'), |
94 | h('section.totals', [COMMENTS, LIKES, SHARES].map(focus => { |
95 | return h('div', |
96 | { |
97 | classList: computed(context.focus, f => f === focus ? [focus, '-selected'] : [focus]), |
98 | 'ev-click': () => context.focus.set(focus) |
99 | }, [ |
100 | h('div.count', totalOnscreenData(focus)), |
101 | h('strong', focus), |
102 | '(30 days)' |
103 | ]) |
104 | })), |
105 | h('section.graph', [ |
106 | canvas, |
107 | h('div.changeRange', [ |
108 | '< ', |
109 | h('a', { 'ev-click': () => howFarBack.set(howFarBack() + 1) }, 'Prev 30 days'), |
110 | ' | ', |
111 | when(howFarBack, |
112 | h('a', { 'ev-click': () => howFarBack.set(howFarBack() - 1) }, 'Next 30 days'), |
113 | h('span', 'Next 30 days') |
114 | ), |
115 | ' >' |
116 | ]) |
117 | ]), |
118 | h('table.blogs', [ |
119 | h('thead', [ |
120 | h('tr', [ |
121 | h('th.details'), |
122 | h('th.comments', 'Comments'), |
123 | h('th.likes', 'Likes'), |
124 | h('th.shares', 'Shares') |
125 | ]) |
126 | ]), |
127 | h('tbody', map(store.blogs, BlogRow)) |
128 | ]) |
129 | ]) |
130 | ]) |
131 | ]) |
132 | |
133 | function BlogRow (blog) { |
134 | const className = computed(context.blog, b => { |
135 | if (!b) return '' |
136 | if (b !== blog.key) return '-background' |
137 | }) |
138 | |
139 | return h('tr.blog', { id: blog.key, className }, [ |
140 | h('td.details', [ |
141 | h('div.title', { |
142 | 'ev-click': () => { |
143 | if (context.blog() === blog.key) context.blog.set('') |
144 | else context.blog.set(blog.key) |
145 | } |
146 | }, getTitle({ blog, mdRenderer: api.message.html.markdown })), |
147 | h('a', { |
148 | href: '#', |
149 | 'ev-click': ev => { |
150 | ev.stopPropagation() // stop the click catcher! |
151 | api.history.sync.push(blog) |
152 | } |
153 | }, 'View blog') |
154 | ]), |
155 | h('td.comments', computed(store.comments.get(blog.key), msgs => msgs ? msgs.length : 0)), |
156 | h('td.likes', computed(store.likes.get(blog.key), msgs => msgs ? msgs.length : 0)), |
157 | h('td.shares', computed(store.shares.get(blog.key), msgs => msgs ? msgs.length : 0)) |
158 | ]) |
159 | } |
160 | |
161 | initialiseChart({ canvas, context, foci }) |
162 | |
163 | return page |
164 | } |
165 | } |
166 | |
167 | function getTitle ({ blog, mdRenderer }) { |
168 | if (blog.value.content.title) return blog.value.content.title |
169 | else if (blog.value.content.text) { |
170 | var md = mdRenderer(marksum.title(blog.value.content.text)) |
171 | if (md && md.innerText) return md.innerText |
172 | } |
173 | |
174 | return blog.key |
175 | } |
176 | |
177 | function fetchBlogData ({ server, store }) { |
178 | const myKey = server.id |
179 | pull( |
180 | server.blogStats.readBlogs({ reverse: false }), |
181 | pull.drain(blog => { |
182 | store.blogs.push(blog) |
183 | |
184 | fetchComments({ server, store, blog }) |
185 | fetchLikes({ server, store, blog }) |
186 | }) |
187 | ) |
188 | |
189 | function fetchComments ({ server, store, blog }) { |
190 | if (!store.comments.has(blog.key)) store.comments.put(blog.key, MutantArray()) |
191 | |
192 | pull( |
193 | server.blogStats.readComments(blog), |
194 | pull.drain(msg => { |
195 | if (msg.value.author === myKey) return |
196 | store.comments.get(blog.key).push(msg) |
197 | }) |
198 | ) |
199 | } |
200 | |
201 | function fetchLikes ({ server, store, blog }) { |
202 | if (!store.likes.has(blog.key)) store.likes.put(blog.key, MutantArray()) |
203 | |
204 | pull( |
205 | server.blogStats.readLikes(blog), |
206 | pull.drain(msg => { |
207 | if (msg.value.author === myKey) return |
208 | |
209 | const isUnlike = get(msg, 'value.content.vote.value', 1) < 1 |
210 | |
211 | var likes = store.likes.get(blog.key) |
212 | var extantLike = likes.find(m => m.value.author === msg.value.author) |
213 | // extant means existing |
214 | |
215 | if (!extantLike) return likes.push(msg) |
216 | else { |
217 | if (msg.value.timestamp < extantLike.value.timestamp) return |
218 | else { |
219 | // case: we have a new like/ unlike value |
220 | if (isUnlike) likes.delete(extantLike) |
221 | else likes.put(likes.indexOf(extantLike), msg) |
222 | } |
223 | } |
224 | }) |
225 | ) |
226 | } |
227 | } |
228 | |
229 | function initialiseChart ({ canvas, context, foci }) { |
230 | var chart = new Chart(canvas.getContext('2d'), chartConfig({ context })) |
231 | |
232 | const chartData = computed([context, foci], (context, foci) => { |
233 | fixAnimationWhenNeeded(context) |
234 | |
235 | const { focus } = context |
236 | // NOTE this filter logic is repeated in totalOnscreenData |
237 | const msgs = foci[focus] |
238 | .filter(msg => { |
239 | if (!context.blog) return true |
240 | // if context.blog is set, filter down to only msgs about that blog |
241 | return getRoot[focus](msg) === context.blog |
242 | }) |
243 | .filter(msg => { |
244 | // don't count unlikes |
245 | if (focus === LIKES) return get(msg, 'value.content.vote.value') > 0 |
246 | else return true |
247 | }) |
248 | |
249 | const grouped = groupBy(msgs, m => toDay(m.value.timestamp)) |
250 | |
251 | return Object.keys(grouped) |
252 | .map(day => { |
253 | return { |
254 | t: day * DAY + 10, |
255 | y: grouped[day].length |
256 | } |
257 | // NOTE - this collects the data points for a day at t = 10ms into the day |
258 | // this is necessary for getting counts to line up (bars, and daily count) |
259 | // I think because total counts for totalOnscreenData don't collect data in the same way? |
260 | // TODO - refactor this, to be tidier |
261 | }) |
262 | }) |
263 | |
264 | chartData(data => { |
265 | chart.data.datasets[0].data = data |
266 | |
267 | chart.update() |
268 | }) |
269 | |
270 | // Scales the height of the graph (to the visible data)! |
271 | watchAll([chartData, context.range], (data, range) => { |
272 | const { lower, upper } = range |
273 | const slice = data |
274 | .filter(d => d.t > lower && d.t <= upper) |
275 | .map(d => d.y) |
276 | .sort((a, b) => a < b) |
277 | |
278 | var h = slice[0] |
279 | if (!h || h < 10) h = 10 |
280 | else h = h + (5 - h % 5) |
281 | // set the height of the graph to a minimum or 10, |
282 | // or some multiple of 5 above the max height |
283 | |
284 | chart.options.scales.yAxes[0].ticks.max = h |
285 | |
286 | chart.update() |
287 | }) |
288 | |
289 | // Update the x-axes bounds of the graph! |
290 | context.range(range => { |
291 | const { lower, upper } = range |
292 | |
293 | chart.options.scales.xAxes[0].time.min = new Date(lower - DAY / 2) |
294 | chart.options.scales.xAxes[0].time.max = new Date(upper - DAY / 2) |
295 | // the squeezing in by DAY/2 is to stop data outside range from half showing |
296 | |
297 | chart.update() |
298 | }) |
299 | |
300 | // ///// HELPERS ///// |
301 | |
302 | // HACK - if the focus has changed, then zero the data |
303 | // this prevents the graph from showing some confusing animations when transforming between foci / selecting blog |
304 | var prevFocus = context.focus() |
305 | var prevBlog = context.blog() |
306 | function fixAnimationWhenNeeded (context) { |
307 | if (context.focus !== prevFocus || context.blog !== prevBlog) { |
308 | chart.data.datasets[0].data = [] |
309 | chart.update() |
310 | prevFocus = context.focus |
311 | prevBlog = context.blog |
312 | } |
313 | } |
314 | function toDay (ts) { return Math.floor(ts / DAY) } |
315 | } |
316 | |
317 | // TODO rm chartData and other overly smart things which didn't work from here |
318 | function chartConfig ({ context }) { |
319 | const { lower, upper } = resolve(context.range) |
320 | |
321 | // Ticktack Primary color:'hsla(215, 57%, 43%, 1)', |
322 | const barColor = 'hsla(215, 57%, 60%, 1)' |
323 | |
324 | return { |
325 | type: 'bar', |
326 | data: { |
327 | datasets: [{ |
328 | backgroundColor: barColor, |
329 | borderColor: barColor, |
330 | data: [] |
331 | }] |
332 | }, |
333 | options: { |
334 | legend: { |
335 | display: false |
336 | }, |
337 | scales: { |
338 | xAxes: [{ |
339 | type: 'time', |
340 | distribution: 'linear', |
341 | time: { |
342 | unit: 'day', |
343 | min: new Date(lower - DAY / 2), |
344 | max: new Date(upper - DAY / 2), |
345 | tooltipFormat: 'MMMM D', |
346 | stepSize: 7 |
347 | }, |
348 | bounds: 'ticks', |
349 | ticks: { |
350 | // maxTicksLimit: 4 |
351 | }, |
352 | gridLines: { |
353 | display: false |
354 | }, |
355 | maxBarThickness: 20 |
356 | }], |
357 | |
358 | yAxes: [{ |
359 | ticks: { |
360 | min: 0, |
361 | suggestedMax: 10, |
362 | // max: Math.max(localMax, 10), |
363 | stepSize: 5 |
364 | } |
365 | }] |
366 | }, |
367 | animation: { |
368 | // duration: 300 |
369 | } |
370 | } |
371 | } |
372 | } |
373 |
Built with git-ssb-web