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