git ssb

2+

mixmix / ticktack



Tree: 290057bfce673063c5629dd99de5b67dab52483a

Files: 290057bfce673063c5629dd99de5b67dab52483a / app / page / statsShow.js

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

Built with git-ssb-web