git ssb

2+

mixmix / ticktack



Tree: b820672852d31940a46c7ac613f5f5c0195f65b8

Files: b820672852d31940a46c7ac613f5f5c0195f65b8 / app / page / statsShow.js

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

Built with git-ssb-web