git ssb

2+

mixmix / ticktack



Tree: ee8e82af460b4067e37ca225319fad117e33b584

Files: ee8e82af460b4067e37ca225319fad117e33b584 / app / page / statsShow.js

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

Built with git-ssb-web