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