Files: e2016e7e072aaa1539f05778a0610bd68e812782 / app / page / statsShow.js
9775 bytesRaw
1 | const nest = require('depnest') |
2 | const { h, resolve, when, Value, Struct, Array: MutantArray, Dict, onceTrue, map, computed, dictToCollection, 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 mergeWith = require('lodash/mergeWith') |
8 | const flatMap = require('lodash/flatMap') |
9 | |
10 | exports.gives = nest('app.page.statsShow') |
11 | |
12 | exports.needs = nest({ |
13 | 'sbot.obs.connection': 'first', |
14 | 'history.sync.push': 'first' |
15 | }) |
16 | |
17 | exports.create = (api) => { |
18 | return nest('app.page.statsShow', statsShow) |
19 | |
20 | function statsShow (location) { |
21 | var store = Struct({ |
22 | blogs: MutantArray([]), |
23 | comments: Dict(), |
24 | likes: Dict() |
25 | }) |
26 | onceTrue(api.sbot.obs.connection, server => { |
27 | fetchBlogs({ server, store }) |
28 | }) |
29 | |
30 | var howFarBack = Value(0) |
31 | // stats show a moving window of 30 days |
32 | const THIRTY_DAYS = 30 * 24 * 60 * 60 * 1000 |
33 | |
34 | const COMMENTS = 'comments' |
35 | const LIKES = 'likes' |
36 | var context = Struct({ |
37 | focus: Value(COMMENTS), |
38 | blog: Value(), |
39 | range: computed([howFarBack], howFarBack => { |
40 | const now = Date.now() |
41 | return { |
42 | upper: now - howFarBack * THIRTY_DAYS, |
43 | lower: now - (howFarBack + 1) * THIRTY_DAYS |
44 | } |
45 | }) |
46 | }) |
47 | |
48 | var commentsAll = computed(throttle(dictToCollection(store.comments), 1000), (msgs) => { |
49 | return msgs |
50 | .map(c => c.value) |
51 | .reduce((n, sofar) => [...n, ...sofar], []) |
52 | }) |
53 | // var commentsAll = computed([throttle(store.comments, 1000)], (msgs) => { |
54 | // return flatMap(msgs, (val, key) => { |
55 | // console.log(key, val) |
56 | // return val |
57 | // }) |
58 | // }) |
59 | |
60 | var visibleCommentsCount = computed([commentsAll, context.range], (msgs, range) => { |
61 | return msgs |
62 | .filter(msg => { |
63 | const ts = msg.value.timestamp |
64 | return ts >= range.lower && ts <= range.upper |
65 | }) |
66 | .length |
67 | }) |
68 | |
69 | var likesAll = computed(throttle(dictToCollection(store.likes), 1000), (msgs) => { |
70 | return msgs |
71 | .map(c => c.value) |
72 | .reduce((n, sofar) => [...n, ...sofar], []) |
73 | }) |
74 | |
75 | var visibleLikesCount = computed([likesAll, context.range], (msgs, range) => { |
76 | return msgs |
77 | .filter(msg => { |
78 | const ts = msg.value.timestamp |
79 | return ts >= range.lower && ts <= range.upper |
80 | }) |
81 | .length |
82 | }) |
83 | |
84 | var focused = Struct({ |
85 | [LIKES]: commentsAll, |
86 | [COMMENTS]: likesAll |
87 | }) |
88 | |
89 | const canvas = h('canvas', { height: 200, width: 600, style: { height: '200px', width: '600px' } }) |
90 | |
91 | const displayComments = () => context.focus.set(COMMENTS) |
92 | const displayLikes = () => context.focus.set(LIKES) |
93 | |
94 | const page = h('Page -statsShow', [ |
95 | h('Scroller.content', [ |
96 | h('div.content', [ |
97 | h('h1', 'Stats'), |
98 | h('section.totals', [ |
99 | h('div.comments', |
100 | { |
101 | className: computed(context.focus, focus => focus === COMMENTS ? '-selected' : ''), |
102 | 'ev-click': displayComments |
103 | }, [ |
104 | h('div.count', visibleCommentsCount), |
105 | h('strong', 'Comments'), |
106 | '(30 days)' |
107 | ]), |
108 | h('div.likes', |
109 | { |
110 | className: computed(context.focus, focus => focus === LIKES ? '-selected' : ''), |
111 | 'ev-click': displayLikes |
112 | }, [ |
113 | h('div.count', visibleLikesCount), |
114 | h('strong', 'Likes'), |
115 | '(30 days)' |
116 | ] |
117 | ), |
118 | h('div.shares', |
119 | { |
120 | className: when(context.shares, '-selected') |
121 | // 'ev-click': displayShares |
122 | }, [ |
123 | // h('div.count', computed(rangeLikes, msgs => msgs.length)), |
124 | h('div.count', '--'), |
125 | h('strong', 'Shares'), |
126 | '(30 days)' |
127 | ] |
128 | ) |
129 | ]), |
130 | h('section.graph', [ |
131 | canvas, |
132 | h('div.changeRange', [ |
133 | '< ', |
134 | h('a', { 'ev-click': () => howFarBack.set(howFarBack() + 1) }, 'Prev 30 days'), |
135 | ' | ', |
136 | when(howFarBack, |
137 | h('a', { 'ev-click': () => howFarBack.set(howFarBack() - 1) }, 'Next 30 days'), |
138 | h('span', 'Next 30 days') |
139 | ), |
140 | ' >' |
141 | ]) |
142 | ]), |
143 | h('table.blogs', [ |
144 | h('thead', [ |
145 | h('tr', [ |
146 | h('th.details'), |
147 | h('th.comment', 'Comments'), |
148 | h('th.likes', 'Likes') |
149 | ]) |
150 | ]), |
151 | h('tbody', map(store.blogs, blog => h('tr.blog', { id: blog.key }, [ |
152 | h('td.details', [ |
153 | h('div.title', {}, getTitle(blog)), |
154 | h('a', |
155 | { |
156 | href: '#', |
157 | 'ev-click': viewBlog(blog) |
158 | }, |
159 | 'View blog' |
160 | ) |
161 | ]), |
162 | h('td.comments', computed(store.comments.get(blog.key), msgs => msgs ? msgs.length : 0)), |
163 | h('td.likes', computed(store.likes.get(blog.key), msgs => msgs ? msgs.length : 0)) |
164 | // ]), { comparer: (a, b) => a === b })) |
165 | ]))) |
166 | ]) |
167 | ]) |
168 | ]) |
169 | ]) |
170 | |
171 | var chart = new Chart(canvas.getContext('2d'), chartConfig({ context, chartData: [] })) |
172 | |
173 | const toDay = ts => Math.floor(ts / (24 * 60 * 60 * 1000)) |
174 | |
175 | // HACK - if the focus has changed, then zero the data |
176 | // this prevents the graph from showing some confusing animations when transforming between foci |
177 | var lastFocus = context.focus() |
178 | const zeroGraphOnFocusChange = (focus) => { |
179 | if (focus !== lastFocus) { |
180 | chart.data.datasets[0].data = [] |
181 | chart.update() |
182 | lastFocus = focus |
183 | } |
184 | } |
185 | const chartData = computed([context.focus, focused], (focus, focused) => { |
186 | zeroGraphOnFocusChange(focus) |
187 | const msgs = focused[focus] |
188 | const grouped = groupBy(msgs, m => toDay(m.value.timestamp)) |
189 | |
190 | var data = Object.keys(grouped) |
191 | .map(day => { |
192 | return { |
193 | t: day * 24 * 60 * 60 * 1000, |
194 | y: grouped[day].length |
195 | } |
196 | }) |
197 | return data |
198 | }) |
199 | |
200 | chartData(data => { |
201 | chart.data.datasets[0].data = data |
202 | |
203 | chart.update() |
204 | }) |
205 | |
206 | const graphHeight = computed([chartData, context.range], (data, range) => { |
207 | const { lower, upper } = range |
208 | const slice = data |
209 | .filter(d => d.t >= lower && d.t <= upper) |
210 | .map(d => d.y) |
211 | .sort((a, b) => a < b) |
212 | const localMax = slice[0] ? Math.max(slice[0], 10) : 10 |
213 | |
214 | return localMax + (5 - localMax % 5) |
215 | }) |
216 | graphHeight(h => { |
217 | chart.options.scales.yAxes[0].ticks.max = h |
218 | |
219 | chart.update() |
220 | }) |
221 | |
222 | context.range(range => { |
223 | const { lower, upper } = range |
224 | |
225 | chart.options.scales.xAxes[0].time.min = new Date(lower) |
226 | chart.options.scales.xAxes[0].time.max = new Date(upper) |
227 | |
228 | chart.update() |
229 | }) |
230 | |
231 | return page |
232 | } |
233 | |
234 | function viewBlog (blog) { |
235 | return () => api.history.sync.push(blog) |
236 | } |
237 | } |
238 | |
239 | function getTitle (blog) { |
240 | if (blog.value.content.title) return blog.value.content.title |
241 | else if (blog.value.content.text) return marksum.title(blog.value.content.text) |
242 | else return blog.key |
243 | } |
244 | |
245 | function fetchBlogs ({ server, store }) { |
246 | pull( |
247 | server.blogStats.readBlogs({ reverse: false }), |
248 | pull.drain(blog => { |
249 | store.blogs.push(blog) |
250 | |
251 | fetchComments({ server, store, blog }) |
252 | fetchLikes({ server, store, blog }) |
253 | }) |
254 | ) |
255 | } |
256 | |
257 | function fetchComments ({ server, store, blog }) { |
258 | if (!store.comments.has(blog.key)) store.comments.put(blog.key, MutantArray()) |
259 | |
260 | pull( |
261 | server.blogStats.readComments(blog), |
262 | pull.drain(msg => { |
263 | store.comments.get(blog.key).push(msg) |
264 | // TODO remove my comments from count? |
265 | }) |
266 | ) |
267 | } |
268 | |
269 | function fetchLikes ({ server, store, blog }) { |
270 | if (!store.likes.has(blog.key)) store.likes.put(blog.key, MutantArray()) |
271 | |
272 | pull( |
273 | server.blogStats.readLikes(blog), |
274 | pull.drain(msg => { |
275 | store.likes.get(blog.key).push(msg) |
276 | // TODO this needs reducing... like + unlike are muddled in here |
277 | // find any thing by same author |
278 | // if exists - over-write or delete |
279 | }) |
280 | ) |
281 | } |
282 | |
283 | function chartConfig ({ context, chartData }) { |
284 | const { lower, upper } = resolve(context.range) |
285 | |
286 | const data = resolve(chartData) || [] |
287 | const slice = data |
288 | .filter(d => d.t >= lower && d.t <= upper) |
289 | .map(d => d.y) |
290 | .sort((a, b) => a < b) |
291 | const localMax = slice[0] ? Math.max(slice[0], 10) : 10 |
292 | |
293 | return { |
294 | type: 'bar', |
295 | data: { |
296 | datasets: [{ |
297 | backgroundColor: 'hsla(215, 57%, 60%, 1)', |
298 | // Ticktack Primary color:'hsla(215, 57%, 43%, 1)', |
299 | borderColor: 'hsla(215, 57%, 60%, 1)', |
300 | data |
301 | }] |
302 | }, |
303 | options: { |
304 | legend: { |
305 | display: false |
306 | }, |
307 | scales: { |
308 | xAxes: [{ |
309 | type: 'time', |
310 | distribution: 'linear', |
311 | time: { |
312 | unit: 'day', |
313 | min: new Date(lower), |
314 | max: new Date(upper), |
315 | tooltipFormat: 'MMMM D', |
316 | stepSize: 7 |
317 | }, |
318 | bounds: 'ticks', |
319 | ticks: { |
320 | // maxTicksLimit: 4 |
321 | }, |
322 | gridLines: { |
323 | display: false |
324 | }, |
325 | maxBarThickness: 20 |
326 | }], |
327 | |
328 | yAxes: [{ |
329 | ticks: { |
330 | min: 0, |
331 | suggestedMax: 10, |
332 | // max: Math.max(localMax, 10), |
333 | stepSize: 5 |
334 | } |
335 | }] |
336 | }, |
337 | animation: { |
338 | // duration: 300 |
339 | } |
340 | } |
341 | } |
342 | } |
343 |
Built with git-ssb-web