git ssb

2+

mixmix / ticktack



Tree: 14ac2ad367289482ef190e031665686d5734bf6a

Files: 14ac2ad367289482ef190e031665686d5734bf6a / app / page / statsShow.js

11053 bytesRaw
1const nest = require('depnest')
2const { h, resolve, when, Value, Struct, Array: MutantArray, Dict, onceTrue, map, computed, throttle, watchAll } = require('mutant')
3const pull = require('pull-stream')
4const marksum = require('markdown-summary')
5const Chart = require('chart.js')
6const groupBy = require('lodash/groupBy')
7const flatMap = require('lodash/flatMap')
8const get = require('lodash/get')
9
10exports.gives = nest('app.page.statsShow')
11
12exports.needs = nest({
13 'sbot.obs.connection': 'first',
14 'history.sync.push': 'first',
15 'message.html.markdown': 'first'
16})
17
18const COMMENTS = 'comments'
19const LIKES = 'likes'
20const SHARES = 'shares'
21const DAY = 24 * 60 * 60 * 1000
22
23const getRoot = {
24 [COMMENTS]: (msg) => get(msg, 'value.content.root'),
25 [LIKES]: (msg) => get(msg, 'value.content.vote.link')
26}
27
28exports.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
167function 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
177function 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
229function 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
318function 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