Files: c4c384106bcf50acf4c0fcb3f7b842ad68f25bcb / app / page / calendar.js
7408 bytesRaw
1 | const nest = require('depnest') |
2 | const { h, Array: MutantArray, map, Struct, computed, watch, throttle, resolve } = require('mutant') |
3 | const Month = require('marama') |
4 | const pull = require('pull-stream') |
5 | |
6 | exports.gives = nest({ |
7 | 'app.page.calendar': true, |
8 | 'app.html.menuItem': true |
9 | }) |
10 | |
11 | exports.needs = nest({ |
12 | 'app.sync.goTo': 'first', |
13 | 'keys.sync.id': 'first', |
14 | 'message.html.render': 'first', |
15 | 'message.sync.unbox': 'first', |
16 | 'sbot.async.get': 'first', |
17 | 'sbot.pull.stream': 'first', |
18 | 'scry.html.button': 'first', |
19 | 'gathering.html.button': 'first' |
20 | }) |
21 | |
22 | exports.create = (api) => { |
23 | return nest({ |
24 | 'app.html.menuItem': menuItem, |
25 | 'app.page.calendar': calendarPage |
26 | }) |
27 | |
28 | function menuItem () { |
29 | return h('a', { |
30 | 'ev-click': () => api.app.sync.goTo({ page: 'calendar' }) |
31 | }, '/calendar') |
32 | } |
33 | |
34 | function calendarPage (location) { |
35 | const d = startOfDay() |
36 | const state = Struct({ |
37 | today: d, |
38 | year: d.getFullYear(), |
39 | events: MutantArray([]), |
40 | attending: MutantArray([]), |
41 | range: Struct({ |
42 | gte: d, |
43 | lt: endOfDay(d) |
44 | }) |
45 | }) |
46 | |
47 | watch(state.year, year => getGatherings(year, state.events, Query)) |
48 | watchAttending(state.attending, api) |
49 | |
50 | const actions = [ |
51 | api.scry.html.button(), |
52 | api.gathering.html.button() |
53 | ] |
54 | |
55 | const page = h('CalendarPage', { title: '/calendar' }, [ |
56 | Calendar(state, actions), |
57 | Events(state, api) |
58 | ]) |
59 | |
60 | page.keyboardScroll = (i) => scroll(state.range, i) |
61 | |
62 | return page |
63 | } |
64 | |
65 | function Query (opts) { |
66 | return api.sbot.pull.stream(server => server.query.read(opts)) |
67 | } |
68 | } |
69 | |
70 | function scroll (range, i) { |
71 | const { gte, lt } = resolve(range) |
72 | |
73 | if (isMonthInterval(gte, lt)) { |
74 | range.gte.set(new Date(gte.getFullYear(), gte.getMonth() + i, gte.getDate())) |
75 | range.lt.set(new Date(lt.getFullYear(), lt.getMonth() + i, lt.getDate())) |
76 | return |
77 | } |
78 | |
79 | if (isWeekInterval(gte, lt)) { |
80 | range.gte.set(new Date(gte.getFullYear(), gte.getMonth(), gte.getDate() + 7 * i)) |
81 | range.lt.set(new Date(lt.getFullYear(), lt.getMonth(), lt.getDate() + 7 * i)) |
82 | return |
83 | } |
84 | |
85 | range.gte.set(new Date(gte.getFullYear(), gte.getMonth(), gte.getDate() + i)) |
86 | range.lt.set(new Date(lt.getFullYear(), lt.getMonth(), lt.getDate() + i)) |
87 | |
88 | function isMonthInterval (gte, lt) { |
89 | return gte.getDate() === 1 && // 1st of month |
90 | lt.getDate() === 1 && // to the 1st of the month |
91 | gte.getMonth() + 1 === lt.getMonth() && // one month gap |
92 | gte.getFullYear() === lt.getFullYear() |
93 | } |
94 | |
95 | function isWeekInterval (gte, lt) { |
96 | return gte.getDay() === 1 && // from monday |
97 | lt.getDay() === 1 && // to just inside monday |
98 | new Date(gte.getFullYear(), gte.getMonth(), gte.getDate() + 7).toISOString() === lt.toISOString() |
99 | } |
100 | } |
101 | |
102 | function Events (state, api) { |
103 | return h('CalendarEvents', { title: '' }, computed([state.events, state.range], (events, range) => { |
104 | const keys = events |
105 | .filter(ev => ev.date >= range.gte && ev.date < range.lt) |
106 | .sort((a, b) => a.date - b.date) |
107 | .map(ev => ev.data.key) |
108 | |
109 | const gatherings = MutantArray([]) |
110 | |
111 | pull( |
112 | pull.values(keys), |
113 | pull.asyncMap((key, cb) => { |
114 | api.sbot.async.get(key, (err, value) => { |
115 | if (err) return cb(err) |
116 | |
117 | if (typeof value.content === 'object') cb(null, { key, value }) |
118 | else cb(null, api.message.sync.unbox({ key, value })) |
119 | }) |
120 | }), |
121 | pull.drain(msg => gatherings.push(msg)) |
122 | ) |
123 | |
124 | return map(gatherings, g => api.message.html.render(g)) |
125 | })) |
126 | } |
127 | |
128 | function watchAttending (attending, api) { |
129 | const myKey = api.keys.sync.id() |
130 | |
131 | const query = [{ |
132 | $filter: { |
133 | value: { |
134 | author: myKey, |
135 | content: { |
136 | type: 'about', |
137 | about: { $is: 'string' }, |
138 | attendee: { link: myKey } |
139 | } |
140 | } |
141 | } |
142 | }, { |
143 | $map: { |
144 | key: ['value', 'content', 'about'], // gathering |
145 | rm: ['value', 'content', 'attendee', 'remove'] |
146 | } |
147 | }] |
148 | |
149 | const opts = { reverse: false, live: true, query } |
150 | |
151 | pull( |
152 | api.sbot.pull.stream(server => server.query.read(opts)), |
153 | pull.filter(m => !m.sync), |
154 | pull.filter(Boolean), |
155 | pull.drain(({ key, rm }) => { |
156 | var hasKey = attending.includes(key) |
157 | |
158 | if (!hasKey && !rm) attending.push(key) |
159 | else if (hasKey && rm) attending.delete(key) |
160 | }) |
161 | ) |
162 | } |
163 | |
164 | function getGatherings (year, events, Query) { |
165 | // gatherings specify times with `about` messages which have a startDateTime |
166 | // NOTE - this gets a window of about messages around the current year but does not gaurentee |
167 | // that we got all events in this year (e.g. something booked 6 months agead would be missed) |
168 | const query = [{ |
169 | $filter: { |
170 | value: { |
171 | timestamp: { // ordered by published time |
172 | $gt: Number(new Date(year - 1, 11, 1)), |
173 | $lt: Number(new Date(year + 1, 0, 1)) |
174 | }, |
175 | content: { |
176 | type: 'about', |
177 | startDateTime: { |
178 | epoch: { $is: 'number' } |
179 | } |
180 | } |
181 | } |
182 | } |
183 | }, { |
184 | $map: { |
185 | key: ['value', 'content', 'about'], |
186 | date: ['value', 'content', 'startDateTime', 'epoch'], |
187 | ts: ['value', 'timestamp'] |
188 | } |
189 | }] |
190 | const opts = { reverse: false, live: true, query } |
191 | |
192 | var target |
193 | pull( |
194 | Query(opts), |
195 | pull.filter(m => !m.sync), |
196 | pull.filter(m => m.date > 0 && Number.isInteger(m.date)), |
197 | pull.map(m => { |
198 | m.date = new Date(m.date) |
199 | return m |
200 | }), |
201 | pull.drain(({ key, date, ts }) => { |
202 | target = events.find(ev => ev.data.key === key) |
203 | if (target && target.data.ts <= ts) events.delete(target) |
204 | // TODO causally sorted about messages |
205 | // could do this with a backlinks query, paramap'd |
206 | |
207 | events.push({ date, data: { key, ts } }) |
208 | }) |
209 | ) |
210 | } |
211 | |
212 | // Thanks to nomand for the inspiration and code (https://github.com/nomand/Letnice), |
213 | // Calendar takes events of format { date: Date, data: { attending: Boolean, ... } } |
214 | |
215 | const MONTH_NAMES = [ 'Ja', 'Fe', 'Ma', 'Ap', 'Ma', 'Ju', 'Ju', 'Au', 'Se', 'Oc', 'No', 'De' ] |
216 | |
217 | function Calendar (state, actions) { |
218 | // TODO assert events is an Array of object |
219 | // of form { date, data } |
220 | |
221 | return h('Calendar', { title: '' }, [ |
222 | h('div.header', [ |
223 | h('div.year', [ |
224 | state.year, |
225 | h('a', { 'ev-click': () => state.year.set(state.year() - 1) }, '-'), |
226 | h('a', { 'ev-click': () => state.year.set(state.year() + 1) }, '+') |
227 | ]), |
228 | h('div.actions', actions) |
229 | ]), |
230 | h('div.months', computed(throttle(state, 100), ({ today, year, events, attending, range }) => { |
231 | events = events.map(ev => { |
232 | ev.data.attending = attending.includes(ev.data.key) |
233 | return ev |
234 | }) |
235 | |
236 | return Array(12).fill(0).map((_, i) => { |
237 | const setMonthRange = (ev) => { |
238 | onSelect({ |
239 | gte: new Date(year, i, 1), |
240 | lt: new Date(year, i + 1, 1) |
241 | }) |
242 | } |
243 | |
244 | return h('div.month', [ |
245 | h('div.month-name', { 'ev-click': setMonthRange }, MONTH_NAMES[i]), |
246 | Month({ year, monthIndex: i, events, range, onSelect, styles: { weekFormat: 'columns' } }) |
247 | ]) |
248 | }) |
249 | })) |
250 | ]) |
251 | |
252 | function onSelect ({ gte, lt }) { |
253 | state.range.set({ gte, lt }) |
254 | } |
255 | } |
256 | |
257 | function startOfDay (d = new Date()) { |
258 | return new Date(d.getFullYear(), d.getMonth(), d.getDate()) |
259 | } |
260 | |
261 | function endOfDay (d = new Date()) { |
262 | return new Date(d.getFullYear(), d.getMonth(), d.getDate() + 1) |
263 | } |
264 |
Built with git-ssb-web