Files: 65cd9cb5b2fda2efbf0c714425830c59b81a884e / app / page / statsShow.js
10273 bytesRaw
1 | const nest = require('depnest') |
2 | const { h, 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 | const chartConfig = require('../../config/chart') |
11 | |
12 | exports.gives = nest('app.page.statsShow') |
13 | |
14 | exports.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 | |
22 | const COMMENTS = 'comments' |
23 | const LIKES = 'likes' |
24 | const SHARES = 'shares' |
25 | const DAY = 24 * 60 * 60 * 1000 |
26 | |
27 | const getRoot = { |
28 | [COMMENTS]: (msg) => get(msg, 'value.content.root'), |
29 | [LIKES]: (msg) => get(msg, 'value.content.vote.link') |
30 | } |
31 | |
32 | exports.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 | |
177 | function 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 | |
187 | function 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 | |
245 | function 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