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