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