├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── frieden.go ├── frieden.service ├── go.mod ├── go.sum ├── screenshot.png └── static ├── css ├── blocks.min.css └── main.css ├── index.html └── js ├── app.js └── torus.min.js /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | frieden 3 | frieden-* 4 | *.exe 5 | *.exe~ 6 | *.dll 7 | *.so 8 | *.dylib 9 | 10 | # Test binary, built with `go test -c` 11 | *.test 12 | 13 | # Output of the go coverage tool, specifically when used with LiteIDE 14 | *.out 15 | 16 | # GCal API Key, etc. 17 | secrets.json 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Linus Lee 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | frieden = ./frieden.go 2 | 3 | all: run 4 | 5 | 6 | run: 7 | go run -race ${frieden} 8 | 9 | 10 | # build for specific OS target 11 | build-%: 12 | GOOS=$* GOARCH=amd64 go build -o frieden-$* ${frieden} 13 | 14 | 15 | build: 16 | go build -o frieden ${frieden} 17 | 18 | 19 | # clean any generated files 20 | clean: 21 | rm -rvf frieden frieden-* 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Frieden ✌️ 2 | 3 | Frieden is my personal, read-only public availability calendar, to reduce the back-and-forth in scheduling one-on-one meetings. Try the live app at [free.linus.zone](https://free.linus.zone). 4 | 5 | Frieden is built with my usual arsenal of homebrew tools: 6 | 7 | - A simple Go backend, proxying Google Calendar's API 8 | - [Torus](https://github.com/thesephist/torus) driving the web UI 9 | - [blocks.css](https://github.com/thesephist/blocks.css) for some visual flair 10 | 11 | ![Screenshot](screenshot.png) 12 | 13 | ## Usage and deployment 14 | 15 | Frieden is deployed as a [systemd service](frieden.service) running as a static binary in Linux. You'll also need your calendars set up correctly with Google Calendar. 16 | 17 | ### Deployment secrets 18 | 19 | To run Frieden, you'll need to place a `secrets.json` file in the root of the project. That file should look like this: 20 | 21 | ```json 22 | { 23 | "apiKey": "", 24 | "calendars": [ 25 | "you@gmail.com", 26 | "another-calendar@gmail.com" 27 | ] 28 | } 29 | ``` 30 | 31 | You'll first need a Google Calendar API key, which you can find [here on their developer site](https://developers.google.com/calendar). Place that in the secrets file as the `"apiKey"`. 32 | 33 | You'll also need to configure each calendar you want Frieden to proxy in the following way: 34 | 35 | 1. Set "Access Permissions" in calendar settings to be public, and "see only free/busy details" at least (if not full details) 36 | 2. Grab the calendar ID (commonly the user email if it's their primary calendar) and place it in `./secrets.json` 37 | 38 | With these permissions, Frieden should be able to access the free/busy details of your calendars. 39 | -------------------------------------------------------------------------------- /frieden.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "io/ioutil" 9 | "log" 10 | "net/http" 11 | "os" 12 | "time" 13 | 14 | "github.com/gorilla/mux" 15 | ) 16 | 17 | // static file server 18 | // api endpoint that runs as a (cached?) proxy to gcal servers 19 | 20 | var config appConfig 21 | 22 | type appConfig struct { 23 | ApiKey string `json:"apiKey"` 24 | CalendarIds []string `json:"calendars"` 25 | } 26 | 27 | type calendarIncomingReq struct { 28 | TimeZone string `json:"timeZone"` 29 | TimeMin string `json:"timeMin"` 30 | TimeMax string `json:"timeMax"` 31 | } 32 | 33 | type calendarOutgoingReq struct { 34 | TimeZone string `json:"timeZone"` 35 | TimeMin string `json:"timeMin"` 36 | TimeMax string `json:"timeMax"` 37 | Calendars []calendarOutgoingReqId `json:"items"` 38 | } 39 | 40 | type calendarOutgoingReqId struct { 41 | Id string `json:"id"` 42 | } 43 | 44 | func mustConfigure() { 45 | configFile, err := os.Open("./secrets.json") 46 | if err != nil { 47 | log.Fatalf("Could not read secrets file!\n\tError: %s", err.Error()) 48 | } 49 | defer configFile.Close() 50 | 51 | configString, err := ioutil.ReadAll(configFile) 52 | if err != nil { 53 | log.Fatalf("Could not read config from file!\n\tError: %s", err.Error()) 54 | } 55 | err = json.Unmarshal(configString, &config) 56 | } 57 | 58 | func handleHome(w http.ResponseWriter, r *http.Request) { 59 | indexFile, err := os.Open("./static/index.html") 60 | if err != nil { 61 | io.WriteString(w, "error reading index") 62 | return 63 | } 64 | defer indexFile.Close() 65 | 66 | io.Copy(w, indexFile) 67 | } 68 | 69 | func getData(w http.ResponseWriter, r *http.Request) { 70 | body, err := ioutil.ReadAll(r.Body) 71 | if err != nil { 72 | io.WriteString(w, "{}") 73 | return 74 | } 75 | 76 | var reqParams calendarIncomingReq 77 | err = json.Unmarshal(body, &reqParams) 78 | if err != nil { 79 | io.WriteString(w, "{}") 80 | return 81 | } 82 | 83 | calendars := []calendarOutgoingReqId{} 84 | for _, id := range config.CalendarIds { 85 | calendars = append(calendars, calendarOutgoingReqId{ 86 | Id: id, 87 | }) 88 | } 89 | outgoingParams := calendarOutgoingReq{ 90 | TimeZone: reqParams.TimeZone, 91 | TimeMin: reqParams.TimeMin, 92 | TimeMax: reqParams.TimeMax, 93 | Calendars: calendars, 94 | } 95 | 96 | jsonStr, err := json.Marshal(&outgoingParams) 97 | proxyReq, err := http.NewRequest( 98 | "POST", 99 | fmt.Sprintf("https://www.googleapis.com/calendar/v3/freeBusy?alt=json&key=%s", config.ApiKey), 100 | bytes.NewBuffer(jsonStr), 101 | ) 102 | if err != nil { 103 | // TODO 104 | log.Fatal("TODO") 105 | } 106 | proxyReq.Header.Set("Accept", "application/json") 107 | proxyReq.Header.Set("Content-Type", "application/json") 108 | 109 | client := &http.Client{} 110 | resp, err := client.Do(proxyReq) 111 | if err != nil { 112 | // TODO 113 | log.Fatal("TODO") 114 | } 115 | defer resp.Body.Close() 116 | 117 | _, err = io.Copy(w, resp.Body) 118 | if err != nil { 119 | // TODO 120 | log.Fatal("TODO") 121 | } 122 | } 123 | 124 | func main() { 125 | mustConfigure() 126 | 127 | r := mux.NewRouter() 128 | 129 | srv := &http.Server{ 130 | Handler: r, 131 | Addr: "127.0.0.1:7856", 132 | WriteTimeout: 60 * time.Second, 133 | ReadTimeout: 60 * time.Second, 134 | } 135 | 136 | r.HandleFunc("/", handleHome) 137 | r.Methods("POST").Path("/data").HandlerFunc(getData) 138 | r.PathPrefix("/static/").Handler(http.StripPrefix("/static/", http.FileServer(http.Dir("./static")))) 139 | 140 | log.Printf("Frieden listening on %s\n", srv.Addr) 141 | log.Fatal(srv.ListenAndServe()) 142 | } 143 | -------------------------------------------------------------------------------- /frieden.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=frieden server 3 | ConditionPathExists=/home/frieden-user/frieden/frieden 4 | After=network.target 5 | 6 | [Service] 7 | Type=simple 8 | User=frieden-user 9 | LimitNOFILE=256 10 | 11 | Restart=on-failure 12 | RestartSec=10 13 | StartLimitIntervalSec=60 14 | 15 | WorkingDirectory=/home/frieden-user/frieden/ 16 | ExecStart=/home/frieden-user/frieden/frieden 17 | 18 | # make sure log directory exists and owned by syslog 19 | PermissionsStartOnly=true 20 | ExecStartPre=/bin/mkdir -p /var/log/frieden 21 | ExecStartPre=/bin/chown syslog:adm /var/log/frieden 22 | ExecStartPre=/bin/chmod 755 /var/log/frieden 23 | StandardOutput=syslog 24 | StandardError=syslog 25 | SyslogIdentifier=frieden 26 | 27 | [Install] 28 | WantedBy=multi-user.target 29 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/thesephist/frieden 2 | 3 | go 1.14 4 | 5 | require github.com/gorilla/mux v1.7.4 6 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/gorilla/mux v1.7.4 h1:VuZ8uybHlWmqV03+zRzdwKL4tUnIp1MAQtp1mIFE1bc= 2 | github.com/gorilla/mux v1.7.4/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= 3 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thesephist/frieden/3bc357b71f804e268f7d45c3b8bed5c57dc86d5b/screenshot.png -------------------------------------------------------------------------------- /static/css/blocks.min.css: -------------------------------------------------------------------------------- 1 | body{--block-text-color:#222;--block-background-color:#fff;--block-accent-color:#00ae86;--block-shadow-color:#444}.block{display:block;color:var(--block-text-color);border:3px solid var(--block-text-color);border-radius:3px;padding:4px 8px;background:var(--block-background-color);font-weight:700;cursor:pointer;box-sizing:border-box;position:relative;top:-2px;left:-2px;transition:transform .2s;margin:8px 6px 10px;z-index:1;user-select:none;-webkit-user-select:none;-moz-user-select:none}.block.wrapper,.block.wrapper.inline{display:inline-block;padding:0}.block.wrapper>*{margin:0}.block:before{content:"";background:var(--block-background-color);border:3px solid var(--block-text-color);border-radius:3px;position:absolute;top:-3px;left:-3px;height:100%;width:100%;z-index:-1}.block:focus,.block:hover{transform:translate(2px,2px)}.block:after{content:"";display:block;background:var(--block-shadow-color);border:3px solid var(--block-text-color);border-radius:3px;height:100%;width:100%;position:absolute;top:3px;left:3px;right:0;z-index:-2;transition:transform .2s}.block:focus:after,.block:hover:after{transform:translate(-2px,-3px)}.block:active{color:var(--block-text-color);transform:translate(3px,3px)}.block:active:after{transform:translate(-4px,-4px)}.block:focus{outline:none}.block.fixed{cursor:auto}.block.fixed:active,.block.fixed:active:after,.block.fixed:active:before,.block.fixed:focus,.block.fixed:focus:after,.block.fixed:focus:before,.block.fixed:hover,.block.fixed:hover:after,.block.fixed:hover:before{transform:none}.block.accent{color:var(--block-background-color)}.block.accent,.block.accent:before{background:var(--block-accent-color)}.block.inline{display:inline-block;font-size:.75em;padding:0 6px;margin:3px 2px 1px 4px}.block.inline:after{top:-1px;left:-1px}.block.inline:focus,.block.inline:hover{transform:translate(1px,1px)}.block.inline:focus:after,.block.inline:hover:after{transform:translate(-1px,-1px)}.block.inline:active{transform:translate(2px,2px)}.block.round,.block.round:after,.block.round:before{border-radius:30px}.block.round:after{left:1px} 2 | -------------------------------------------------------------------------------- /static/css/main.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | font-family: system-ui, sans-serif; 4 | font-size: 16px; 5 | margin: 0; 6 | padding: 0; 7 | --block-accent-color: #b9350e; 8 | } 9 | 10 | #root { 11 | height: 100vh; 12 | max-width: 1000px; 13 | margin-left: auto; 14 | margin-right: auto; 15 | } 16 | 17 | .app { 18 | height: 100%; 19 | width: 100%; 20 | overflow: hidden; 21 | display: flex; 22 | flex-direction: column; 23 | align-items: center; 24 | } 25 | 26 | header { 27 | overflow: hidden; 28 | } 29 | 30 | .buttonGroup { 31 | display: flex; 32 | flex-direction: row; 33 | align-items: center; 34 | } 35 | 36 | header a { 37 | text-decoration: none; 38 | } 39 | 40 | header, h1, nav, .days { 41 | width: 100%; 42 | } 43 | 44 | button { 45 | font-size: 1rem; 46 | } 47 | 48 | h1 { 49 | font-size: 1rem; 50 | font-weight: normal; 51 | display: flex; 52 | flex-direction: row; 53 | align-items: center; 54 | justify-content: space-between; 55 | } 56 | 57 | h1 .main { 58 | font-size: 1.2em; 59 | text-decoration: underline; 60 | margin-right: 6px; 61 | } 62 | 63 | h1 .sub { 64 | color: #999; 65 | font-size: 1em; 66 | white-space: nowrap; 67 | } 68 | 69 | h1 .red { 70 | color: var(--block-accent-color); 71 | } 72 | 73 | nav { 74 | display: flex; 75 | flex-direction: row; 76 | align-items: center; 77 | justify-content: space-between; 78 | } 79 | 80 | nav button { 81 | white-space: nowrap; 82 | } 83 | 84 | .daysBox { 85 | width: calc(100% - 8px); 86 | flex-shrink: 1; 87 | flex-grow: 1; 88 | height: 0; 89 | padding: 0; 90 | } 91 | 92 | .days { 93 | display: flex; 94 | flex-direction: row; 95 | align-items: flex-start; 96 | justify-content: space-between; 97 | font-size: 14px; 98 | width: 100%; 99 | overflow-y: auto; 100 | -webkit-overflow-scrolling: touch; 101 | height: 100%; 102 | } 103 | 104 | .days .day { 105 | display: flex; 106 | flex-direction: column; 107 | align-items: center; 108 | justify-content: flex-start; 109 | width: 100%; 110 | } 111 | 112 | .day { 113 | overflow: hidden; 114 | } 115 | 116 | .dateBox { 117 | width: 100%; 118 | padding: 0; 119 | padding-top: 50px; 120 | position: relative; 121 | overflow: hidden; 122 | } 123 | 124 | .dateLabel { 125 | position: absolute; 126 | top: 0; 127 | z-index: 1; 128 | height: 50px; 129 | overflow: hidden; 130 | display: flex; 131 | flex-direction: column; 132 | align-items: center; 133 | justify-content: center; 134 | font-weight: normal; 135 | border-bottom: 2px solid var(--block-text-color); 136 | width: 100%; 137 | background-color: var(--block-background-color); 138 | } 139 | 140 | .dateLabel.accent { 141 | background: #f4e1dc; 142 | } 143 | 144 | .dateLabel.accent h2, .dateLabel.accent p { 145 | color: var(--block-accent-color); 146 | } 147 | 148 | .dateLabel h2, .dateLabel p { 149 | margin: 0; 150 | } 151 | 152 | .dateLabel h2 { 153 | font-size: 1em; 154 | margin-bottom: 2px; 155 | } 156 | 157 | .dateLabel p { 158 | font-size: 12px; 159 | color: #777; 160 | } 161 | 162 | .hour { 163 | height: 50px; 164 | position: relative; 165 | box-sizing: border-box; 166 | } 167 | 168 | .hourAnnotation { 169 | font-size: 12px; 170 | position: absolute; 171 | display: block; 172 | top: 0; 173 | left: 50%; 174 | transform: translate(-50%, -50%); 175 | color: #999; 176 | } 177 | 178 | .now { 179 | font-size: 1em; 180 | position: absolute; 181 | display: block; 182 | top: 0; 183 | left: 50%; 184 | transform: translate(-50%, -50%); 185 | color: var(--block-accent-color); 186 | background-color: rgba(255, 255, 255, .7); 187 | } 188 | 189 | .now::before, .now::after, 190 | .hourAnnotation::before, .hourAnnotation::after { 191 | content: ''; 192 | display: block; 193 | position: absolute; 194 | top: 50%; 195 | width: 500px; 196 | border-top: 1px solid #999; 197 | } 198 | 199 | .now::before, 200 | .hourAnnotation::before { 201 | left: calc(100% + 4px); 202 | } 203 | 204 | .now::after, 205 | .hourAnnotation::after { 206 | right: calc(100% + 4px); 207 | } 208 | 209 | .now::before, .now::after { 210 | border-color: var(--block-accent-color); 211 | border-width: 3px; 212 | } 213 | 214 | .hour:nth-of-type(odd) { 215 | background: #eee; 216 | } 217 | 218 | .day .slot { 219 | position: absolute; 220 | left: 0; 221 | right: 0; 222 | background-color: var(--block-accent-color); 223 | color: var(--block-background-color); 224 | display: flex; 225 | flex-direction: column; 226 | align-items: center; 227 | justify-content: center; 228 | text-align: center; 229 | padding: 4px; 230 | box-sizing: border-box; 231 | font-size: 12px; 232 | border-radius: 4px; 233 | border: 2px solid var(--block-text-color); 234 | } 235 | 236 | .datePickerWrapper { 237 | position: fixed; 238 | top: 0; 239 | left: 0; 240 | right: 0; 241 | bottom: 0; 242 | background: rgba(0, 0, 0, .35); 243 | z-index: 10; 244 | } 245 | 246 | .datePicker { 247 | position: absolute; 248 | top: 50%; 249 | left: 50%; 250 | transform: translate(-50%, -50%) !important; 251 | display: flex; 252 | flex-direction: column; 253 | align-items: flex-start; 254 | justify-content: flex-start; 255 | } 256 | 257 | .datePicker .title { 258 | margin: 10px 6px; 259 | } 260 | 261 | .selectWrapper { 262 | padding: 0; 263 | width: 200px; 264 | } 265 | 266 | .selectWrapper select { 267 | font-weight: bold; 268 | padding: 6px 8px; 269 | width: 100%; 270 | font-size: 1em; 271 | } 272 | 273 | .openDatePickerButton { 274 | white-space: nowrap; 275 | } 276 | 277 | .setDateButton { 278 | margin-left: auto; 279 | } 280 | 281 | @media only screen and (max-width: 1008px) { 282 | #root { 283 | width: calc(100% - 8px); 284 | } 285 | } 286 | -------------------------------------------------------------------------------- /static/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | When is Linus busy? 4 | 5 | 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /static/js/app.js: -------------------------------------------------------------------------------- 1 | const { 2 | Component, 3 | } = window.Torus; 4 | 5 | const MINUTE = 60 * 1000; 6 | const HOUR = 60 * MINUTE; 7 | const DAY = 24 * HOUR; 8 | const TZOFFSET = new Date().getTimezoneOffset() * MINUTE; 9 | 10 | const HOUR_HEIGHT = 50; // px 11 | const timeToPx = t => t / HOUR * HOUR_HEIGHT; 12 | 13 | const _currentYear = new Date().getFullYear(); 14 | const YEARS = [ 15 | _currentYear - 1, 16 | _currentYear, 17 | _currentYear + 1, 18 | ]; 19 | 20 | const MONTHS = [ 21 | 'January', 22 | 'February', 23 | 'March', 24 | 'April', 25 | 'May', 26 | 'June', 27 | 'July', 28 | 'August', 29 | 'September', 30 | 'October', 31 | 'November', 32 | 'December', 33 | ]; 34 | 35 | const DAYS_OF_WEEK = [ 36 | 'Sun', 37 | 'Mon', 38 | 'Tue', 39 | 'Wed', 40 | 'Thu', 41 | 'Fri', 42 | 'Sat', 43 | ]; 44 | 45 | //> Shorthand for JS timestamp 46 | const ut = () => +(new Date()); 47 | 48 | //> Round date to the top of the day 49 | const dfloor = t => { 50 | const ut = t - TZOFFSET; 51 | const rounded = ut - ut % DAY; 52 | return rounded + TZOFFSET; 53 | } 54 | 55 | //> UNIX timestamp -> ISO Date string 56 | const dfmt = t => new Date(t).toISOString(); 57 | 58 | //> Filter slots to find ones in a date range 59 | const dfilterSlots = (start, end, slots) => slots.filter(s => { 60 | return start < s.end && end > s.start; 61 | }); 62 | 63 | //> Humanize date format 64 | const dhuman = t => { 65 | const dt = new Date(t); 66 | const year = dt.getFullYear(); 67 | const month = dt.getMonth() + 1; 68 | const date = dt.getDate(); 69 | if (year === new Date().getFullYear()) { 70 | return `${month}/${date}`; 71 | } 72 | 73 | return `${year}/${month}/${date}`; 74 | } 75 | 76 | //> Humanize time format 77 | const thuman = t => { 78 | const dt = new Date(t); 79 | const hh = dt.getHours() % 12 || 12; 80 | const mm = dt.getMinutes().toString().padStart(2, '0'); 81 | const ampm = (t - TZOFFSET) % DAY < DAY / 2 ? 'AM' : 'PM'; 82 | 83 | if (mm === '00') { 84 | return `${hh}\xa0${ampm}`; 85 | } 86 | 87 | return `${hh}:${mm}\xa0${ampm}`; 88 | } 89 | 90 | //> Debounce coalesces multiple calls to the same function in a short 91 | // period of time into one call, by cancelling subsequent calls within 92 | // a given timeframe. 93 | const debounce = (fn, delayMillis) => { 94 | let lastRun = 0; 95 | let to = null; 96 | return (...args) => { 97 | clearTimeout(to); 98 | const now = Date.now(); 99 | const dfn = () => { 100 | lastRun = now; 101 | fn(...args); 102 | } 103 | if (now - lastRun > delayMillis) { 104 | dfn() 105 | } else { 106 | to = setTimeout(dfn, delayMillis); 107 | } 108 | } 109 | } 110 | 111 | async function getBusySlots(start, days) { 112 | const end = start + days * DAY; 113 | const data = await fetch('/data', { 114 | method: 'POST', 115 | body: JSON.stringify({ 116 | timeMin: dfmt(start), 117 | timeMax: dfmt(end), 118 | }) 119 | }).then(resp => resp.json()); 120 | const calendars = Object.values(data.calendars); 121 | const slots = []; 122 | for (const cal of calendars) { 123 | for (const slot of cal.busy) { 124 | slots.push({ 125 | start: +new Date(slot.start), 126 | end: +new Date(slot.end), 127 | }); 128 | } 129 | } 130 | return slots; 131 | } 132 | 133 | function Slot(slot, t) { 134 | const startHour = (slot.start - TZOFFSET) % DAY; 135 | const daysPrevious = (dfloor(slot.start) - t) / DAY; 136 | const duration = slot.end - slot.start; 137 | return jdom`
141 | ${thuman(slot.start)} - ${thuman(slot.end)} 142 |
`; 143 | } 144 | 145 | function Hour(hour) { 146 | if (hour === 0) { 147 | return jdom`
`; 148 | } 149 | 150 | const hh = hour % 12 || 12; 151 | let ampm = hour / 12 > 1 ? 'PM' : 'AM'; 152 | if (hh === 12) { 153 | ampm = ampm === 'AM' ? 'PM ' : 'AM'; 154 | } 155 | return jdom`
156 |
157 | ${hh} ${ampm} 158 |
159 |
`; 160 | } 161 | 162 | function Day(t, slots, daysPerScreen) { 163 | const isToday = t === dfloor(ut()); 164 | const nowBar = jdom`
166 | now 167 |
`; 168 | 169 | const hours = []; 170 | for (let i = 0; i < 24; i ++) { 171 | hours.push(Hour(i)); 172 | } 173 | 174 | const slotViews = []; 175 | for (const slot of dfilterSlots(t, t + DAY, slots)) { 176 | slotViews.push(Slot(slot, t)); 177 | } 178 | 179 | return jdom`
180 |
181 |

${DAYS_OF_WEEK[new Date(t).getDay()]}

182 |

${dhuman(t)}

183 |
184 |
185 | ${hours} 186 | ${slotViews} 187 | ${isToday ? nowBar : null} 188 |
189 |
`; 190 | } 191 | 192 | class DatePicker extends Component { 193 | 194 | init(setDate) { 195 | this._invalid = false; 196 | 197 | const d = new Date(ut()); 198 | this.year = d.getFullYear(); 199 | this.month = d.getMonth(); 200 | this.date = d.getDate(); 201 | 202 | this.handleYear = this.handleInput.bind(this, 'year'); 203 | this.handleMonth = this.handleInput.bind(this, 'month'); 204 | this.handleDate = this.handleInput.bind(this, 'date'); 205 | 206 | this.setDate = () => { 207 | const d = new Date(dfloor(ut())); 208 | d.setFullYear(this.year); 209 | d.setMonth(this.month); 210 | d.setDate(this.date); 211 | setDate(+d); 212 | }; 213 | } 214 | 215 | handleInput(label, evt) { 216 | const prev = this[label]; 217 | this[label] = +evt.target.value; 218 | 219 | //> Test if the new date is valid 220 | const d = new Date(dfloor(ut())); 221 | d.setFullYear(this.year); 222 | d.setMonth(this.month); 223 | d.setDate(this.date); 224 | 225 | const t = +d; 226 | this._invalid = isNaN(t); 227 | 228 | this.render(); 229 | } 230 | 231 | assignDate(t) { 232 | const d = new Date(t); 233 | this.year = d.getFullYear(); 234 | this.month = d.getMonth(); 235 | this.date = d.getDate(); 236 | this.render(); 237 | } 238 | 239 | compose() { 240 | return jdom`
241 |
242 |

Pick a date...

243 | 244 |
245 | 249 |
250 | 251 |
252 | 256 |
257 | 258 |
259 | 265 |
266 | 267 | ${this._invalid ? ( 268 | jdom`

* Invalid date

` 269 | ) : ( 270 | jdom`` 274 | )} 275 |
276 |
`; 277 | } 278 | 279 | } 280 | 281 | class App extends Component { 282 | 283 | init() { 284 | this._firstScrolled = false; 285 | this.isFetching = false; 286 | this.lastFetchedDay = 0; 287 | this.lastFetchedDaysPerScreen = 0; 288 | 289 | this._dp = false; 290 | this.datePicker = new DatePicker(d => { 291 | this.day = dfloor(d); 292 | this._dp = false; 293 | this.render(); 294 | this.fetch(); 295 | }); 296 | 297 | this.day = dfloor(ut()); 298 | this.daysPerScreen = 3; 299 | this.slots = []; 300 | 301 | this.handleToday = this.adjustToday.bind(this); 302 | this.handleLeftDay = this.adjustDate.bind(this, -1); 303 | this.handleRightDay = this.adjustDate.bind(this, 1); 304 | this.handleLeftWeek = this.adjustDate.bind(this, -7); 305 | this.handleRightWeek = this.adjustDate.bind(this, 7); 306 | this.resize = debounce(this.resize.bind(this), 600); 307 | this.fetch = debounce(this.fetch.bind(this), 600); 308 | 309 | window.addEventListener('resize', this.resize); 310 | 311 | this.resize(); 312 | } 313 | 314 | remove() { 315 | this.super.remove(); 316 | window.removeEventListener('resize', this.resize); 317 | } 318 | 319 | resize() { 320 | const w = window.innerWidth; 321 | const count = ~~(w / 150); 322 | 323 | if (count <= 3) { 324 | this.daysPerScreen = 3; 325 | } else if (count == 4) { 326 | this.daysPerScreen = 4; 327 | } else if (count == 5) { 328 | this.daysPerScreen = 5; 329 | } else if (count == 6) { 330 | this.daysPerScreen = 6; 331 | } else { 332 | this.daysPerScreen = 7; 333 | } 334 | 335 | //> We render once before fetching b/c fetch's render trigger 336 | // may come after a network elay 337 | this.render(); 338 | this.fetch(); 339 | } 340 | 341 | async fetch() { 342 | if (this.lastFetchedDay === this.day && this.lastFetchedDaysPerScreen === this.daysPerScreen) { 343 | return 344 | } 345 | 346 | this.isFetching = true; 347 | this.lastFetchedDay = this.day; 348 | this.lastFetchedDaysPerScreen = this.daysPerScreen; 349 | 350 | this.render(); 351 | this.slots = await getBusySlots(this.day, this.daysPerScreen); 352 | 353 | this.isFetching = false; 354 | this.render(); 355 | 356 | //> Scroll to 8AM after first fetch 357 | if (!this._firstScrolled) { 358 | this._firstScrolled = true; 359 | this.node.querySelector('.days').scrollTop = 8 * HOUR_HEIGHT; 360 | } 361 | } 362 | 363 | adjustDate(daysOffset) { 364 | this.day += daysOffset * DAY; 365 | 366 | //> We render once before fetching b/c fetch's render trigger 367 | // may come after a network elay 368 | this.render(); 369 | 370 | this.fetch(); 371 | } 372 | 373 | adjustToday() { 374 | this.day = dfloor(ut()); 375 | this.render(); 376 | 377 | this.fetch(); 378 | } 379 | 380 | compose() { 381 | const days = []; 382 | for (let i = 0; i < this.daysPerScreen; i ++) { 383 | days.push(Day(this.day + i * DAY, this.slots, this.daysPerScreen)); 384 | } 385 | 386 | return jdom`
387 |
388 |

389 |
${this.isFetching ? jdom`loading calendar...`: jdom` 390 | When is Linus busy? 391 | (I'm busy in red boxes) 392 | `}
393 |
394 | ? 395 | 400 |
401 |

402 | 414 |
415 |
416 |
417 | ${days} 418 |
419 |
420 | ${this._dp ? this.datePicker.node : null} 421 |
`; 422 | } 423 | } 424 | 425 | const app = new App(); 426 | document.getElementById('root').appendChild(app.node); 427 | -------------------------------------------------------------------------------- /static/js/torus.min.js: -------------------------------------------------------------------------------- 1 | !function(t){var e={};function s(r){if(e[r])return e[r].exports;var n=e[r]={i:r,l:!1,exports:{}};return t[r].call(n.exports,n,n.exports,s),n.l=!0,n.exports}s.m=t,s.c=e,s.d=function(t,e,r){s.o(t,e)||Object.defineProperty(t,e,{enumerable:!0,get:r})},s.r=function(t){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(t,"__esModule",{value:!0})},s.t=function(t,e){if(1&e&&(t=s(t)),8&e)return t;if(4&e&&"object"==typeof t&&t&&t.__esModule)return t;var r=Object.create(null);if(s.r(r),Object.defineProperty(r,"default",{enumerable:!0,value:t}),2&e&&"string"!=typeof t)for(var n in t)s.d(r,n,function(e){return t[e]}.bind(null,n));return r},s.n=function(t){var e=t&&t.__esModule?function(){return t.default}:function(){return t};return s.d(e,"a",e),e},s.o=function(t,e){return Object.prototype.hasOwnProperty.call(t,e)},s.p="",s(s.s=0)}([function(t,e,s){const{render:r,Component:n,Styled:o,StyledComponent:i,List:c,ListOf:l,Record:a,Store:d,StoreOf:u,Router:h}=s(1),{jdom:f,css:m}=s(2);t.exports={render:r,Component:n,Styled:o,StyledComponent:i,List:c,ListOf:l,Record:a,Store:d,StoreOf:u,Router:h,jdom:f,css:m}},function(t,e,s){let r=0;const n=t=>null!==t&&"object"==typeof t,o=t=>{void 0===t.attrs&&(t.attrs={}),void 0===t.events&&(t.events={}),void 0===t.children&&(t.children=[])},i=t=>Array.isArray(t)?t:[t],c=()=>document.createComment("");let l=[];const a={replaceChild:()=>{}};const d=(t,e,s)=>{for(const r of Object.keys(t)){const n=i(t[r]),o=i(e[r]||[]);for(const t of n)o.includes(t)||"function"!=typeof t||s(r,t)}},u=(t,e,s)=>{const i=e=>{t&&t!==e&&l.push([2,t,e]),t=e};if(r++,e!==s)if(null===s)i(c());else if("string"==typeof s||"number"==typeof s)"string"==typeof e||"number"==typeof e?t.data=s:i(document.createTextNode(s));else if(void 0!==s.appendChild)i(s);else{(void 0===t||!n(e)||e&&void 0!==e.appendChild||e.tag!==s.tag)&&(e={tag:null},i(document.createElement(s.tag))),o(e),o(s);for(const r of Object.keys(s.attrs)){const n=e.attrs[r],o=s.attrs[r];if("class"===r){const e=o;Array.isArray(e)?t.className=e.join(" "):t.className=e}else if("style"===r){const e=n||{},s=o;for(const r of Object.keys(s))s[r]!==e[r]&&(t.style[r]=s[r]);for(const r of Object.keys(e))void 0===s[r]&&(t.style[r]="")}else r in t?(t[r]!==o||void 0===n&&n!==o)&&(t[r]=o):n!==o&&t.setAttribute(r,o)}for(const r of Object.keys(e.attrs))void 0===s.attrs[r]&&(r in t?t[r]=null:t.removeAttribute(r));d(s.events,e.events,(e,s)=>{t.addEventListener(e,s)}),d(e.events,s.events,(e,s)=>{t.removeEventListener(e,s)});const r=e.children,c=s.children,a=r.length,h=c.length;if(h+a>0){const n=e._nodes||[],o=a{}},this.init(...t),void 0===this.node&&this.render()}static from(t){return class extends h{init(...t){this.args=t}compose(){return t(...this.args)}}}init(){}get record(){return this.event.source}bind(t,e){if(this.unbind(),!(t instanceof j))throw new Error(`cannot bind to ${t}, which is not an instance of Evented.`);this.event={source:t,handler:e},t.addHandler(e)}unbind(){this.record&&this.record.removeHandler(this.event.handler),this.event={source:null,handler:()=>{}}}remove(){this.unbind()}compose(){return null}preprocess(t){return t}render(t){t=t||this.record&&this.record.summarize();const e=this.preprocess(this.compose(t),t);if(void 0===e)throw new Error(this.constructor.name+".compose() returned undefined.");try{this.node=u(this.node,this.jdom,e)}catch(t){console.error("rendering error.",t)}return this.jdom=e}}const f=new Set;let m;const p=new WeakMap,v=(t,e)=>t+"{"+e+"}",b=(t,e)=>{let s=[],r="";for(const n of Object.keys(e)){const o=e[n];if("@"===n[0])n.startsWith("@media")?s.push(v(n,b(t,o).join(""))):s.push(v(n,b("",o).join("")));else if("object"==typeof o){const e=n.split(",");for(const r of e)if(r.includes("&")){const e=r.replace(/&/g,t);s=s.concat(b(e,o))}else s=s.concat(b(t+" "+r,o))}else r+=n+":"+o+";"}return r&&s.unshift(v(t,r)),s},g=t=>{const e=(t=>{if(!p.has(t)){const e=JSON.stringify(t);let s=e.length,r=1989;for(;s;)r=13*r^e.charCodeAt(--s);p.set(t,"_torus"+(r>>>0))}return p.get(t)})(t);let s=0;if(!f.has(e)){m||(()=>{const t=document.createElement("style");t.setAttribute("data-torus",""),document.head.appendChild(t),m=t.sheet})();const r=b("."+e,t);for(const t of r)m.insertRule(t,s++);f.add(e)}return e},y=t=>class extends t{styles(){return{}}preprocess(t,e){return n(t)&&(t.attrs=t.attrs||{},t.attrs.class=i(t.attrs.class||[]),t.attrs.class.push(g(this.styles(e)))),t}};class x extends h{get itemClass(){return h}init(t,...e){this.store=t,this.items=new Map,this.filterFn=null,this.itemData=e,this.bind(this.store,()=>this.itemsChanged())}itemsChanged(){const t=this.store.summarize(),e=this.items;for(const s of e.keys())t.includes(s)||(e.get(s).remove(),e.delete(s));for(const s of t)e.has(s)||e.set(s,new this.itemClass(s,()=>this.store.remove(s),...this.itemData));let s=[...e.entries()];null!==this.filterFn&&(s=s.filter(t=>this.filterFn(t[0]))),s.sort((e,s)=>t.indexOf(e[0])-t.indexOf(s[0])),this.items=new Map(s),this.render()}filter(t){this.filterFn=t,this.itemsChanged()}unfilter(){this.filterFn=null,this.itemsChanged()}get components(){return[...this]}get nodes(){return this.components.map(t=>t.node)}[Symbol.iterator](){return this.items.values()}remove(){super.remove();for(const t of this.items.values())t.remove()}compose(){return{tag:"ul",children:this.nodes}}}class j{constructor(){this.handlers=new Set}summarize(){}emitEvent(){const t=this.summarize();for(const e of this.handlers)e(t)}addHandler(t){this.handlers.add(t),t(this.summarize())}removeHandler(t){this.handlers.delete(t)}}class w extends j{constructor(t,e={}){super(),n(t)&&(e=t,t=null),this.id=t,this.data=e}update(t){Object.assign(this.data,t),this.emitEvent()}get(t){return this.data[t]}summarize(){return Object.assign({id:this.id},this.data)}serialize(){return this.summarize()}}class O extends j{constructor(t=[]){super(),this.reset(t)}get recordClass(){return w}get comparator(){return null}create(t,e){return this.add(new this.recordClass(t,e))}add(t){return this.records.add(t),this.emitEvent(),t}remove(t){return this.records.delete(t),this.emitEvent(),t}[Symbol.iterator](){return this.records.values()}find(t){for(const e of this.records)if(e.id===t)return e;return null}reset(t){this.records=new Set(t),this.emitEvent()}summarize(){return[...this.records].map(t=>[this.comparator?this.comparator(t):null,t]).sort((t,e)=>t[0]e[0]?1:0).map(t=>t[1])}serialize(){return this.summarize().map(t=>t.serialize())}}const C=t=>{let e;const s=[];for(;null!==e;)if(e=/:\w+/.exec(t),e){const r=e[0];s.push(r.substr(1)),t=t.replace(r,"(.+)")}return[new RegExp(t),s]};const S={render:u,Component:h,Styled:y,StyledComponent:y(h),List:x,ListOf:t=>class extends x{get itemClass(){return t}},Record:w,Store:O,StoreOf:t=>class extends O{get recordClass(){return t}},Router:class extends j{constructor(t){super(),this.routes=Object.entries(t).map(([t,e])=>[t,...C(e)]),this.lastMatch=["",null],this._cb=()=>this.route(location.pathname),window.addEventListener("popstate",this._cb),this._cb()}summarize(){return this.lastMatch}go(t,{replace:e=!1}={}){window.location.pathname!==t&&(e?history.replaceState(null,document.title,t):history.pushState(null,document.title,t),this.route(t))}route(t){for(const[e,s,r]of this.routes){const n=s.exec(t);if(null!==n){const t={},s=n.slice(1);r.forEach((e,r)=>t[e]=s[r]),this.lastMatch=[e,t];break}}this.emitEvent()}remove(){window.removeEventListener("popstate",this._cb)}}};"object"==typeof window&&(window.Torus=S),t.exports&&(t.exports=S)},function(t,e,s){const r=t=>null!==t&&"object"==typeof t,n=(t,e)=>t.substr(0,t.length-e.length),o=(t,e)=>{let s=t[0];for(let r=1,n=e.length;r<=n;r++)s+=e[r-1]+t[r];return s};class i{constructor(t){this.idx=0,this.content=t,this.len=t.length}next(){const t=this.content[this.idx++];return void 0===t&&(this.idx=this.len),t}back(){this.idx--}readUpto(t){const e=this.content.substr(this.idx).indexOf(t);return this.toNext(e)}readUntil(t){const e=this.content.substr(this.idx).indexOf(t)+t.length;return this.toNext(e)}toNext(t){const e=this.content.substr(this.idx);if(-1===t)return this.idx=this.len,e;{const s=e.substr(0,t);return this.idx+=t,s}}clipEnd(t){return!!this.content.endsWith(t)&&(this.content=n(this.content,t),!0)}}const c=t=>{let e="";for(let s=0,r=t.length;s{if("!"===(t=t.trim())[0])return{jdom:null,selfClosing:!0};if(!t.includes(" ")){const e=t.endsWith("/");return{jdom:{tag:e?n(t,"/"):t,attrs:{},events:{}},selfClosing:e}}const e=new i(t),s=e.clipEnd("/");let r="",o=!1,l=!1;const a=[];let d=0;const u=t=>{r=r.trim(),(""!==r||t)&&(a.push({type:d,value:r}),o=!1,r="")};for(let t=e.next();void 0!==t;t=e.next())switch(t){case"=":l?r+=t:(u(),o=!0,d=1);break;case" ":l?r+=t:o||(u(),d=0);break;case"\\":l&&(t=e.next(),r+=t);break;case'"':l?(l=!1,u(!0),d=0):1===d&&(l=!0);break;default:r+=t,o=!1}u();let h="";const f={},m={};h=a.shift().value;let p=null,v=a.shift();const b=()=>{p=v,v=a.shift()};for(;void 0!==v;){if(1===v.type){const t=p.value;let e=v.value.trim();if(t.startsWith("on"))m[t.substr(2)]=[e];else if("class"===t)""!==e&&(f[t]=e.split(" "));else if("style"===t){e.endsWith(";")&&(e=e.substr(0,e.length-1));const s={};for(const t of e.split(";")){const e=t.indexOf(":"),r=t.substr(0,e),n=t.substr(e+1);s[c(r.trim())]=n.trim()}f[t]=s}else f[t]=e;b()}else p&&(f[p.value]=!0);b()}return p&&0===p.type&&(f[p.value]=!0),{jdom:{tag:h,attrs:f,events:m},selfClosing:s}},a=t=>{const e=[];let s=null,r=!1;const n=()=>{r&&""===s.trim()||s&&e.push(s),s=null,r=!1},o=t=>{!1===r&&(n(),r=!0,s=""),s+=t};for(let e=t.next();void 0!==e;e=t.next())if("<"===e){if(n(),"/"===t.next()){t.readUntil(">");break}{t.back();const e=l(t.readUpto(">"));t.next(),s=e&&e.jdom,e.selfClosing||null===s||(s.children=a(t))}}else o("&"===e?(i=e+t.readUntil(";"),String.fromCodePoint(+/&#(\w+);/.exec(i)[1])):e);var i;return n(),e},d=new Map,u=/jdom_tpl_obj_\[(\d+)\]/,h=(t,e)=>{if((t=>"string"==typeof t&&t.includes("jdom_tpl_"))(t)){const s=u.exec(t),r=t.split(s[0]),n=s[1],o=h(r[1],e);let i=[];return""!==r[0]&&i.push(r[0]),Array.isArray(e[n])?i=i.concat(e[n]):i.push(e[n]),0!==o.length&&(i=i.concat(o)),i}return""!==t?[t]:[]},f=(t,e)=>{const s=[];for(const n of t)for(const t of h(n,e))r(t)&&v(t,e),s.push(t);const n=s[0],o=s[s.length-1];return"string"==typeof n&&""===n.trim()&&s.shift(),"string"==typeof o&&""===o.trim()&&s.pop(),s},m=(t,e)=>{if(t.length<14)return t;{const s=u.exec(t);if(null===s)return t;if(t.trim()===s[0])return e[s[1]];{const r=t.split(s[0]);return r[0]+e[s[1]]+m(r[1],e)}}},p=(t,e)=>{for(let s=0,r=t.length;s{for(const s of Object.keys(t)){const n=t[s];"string"==typeof n?t[s]=m(n,e):Array.isArray(n)?"children"===s?t.children=f(n,e):p(n,e):r(n)&&v(n,e)}},b=t=>{const e={};let s=0,r=["",""];const n=()=>{"string"==typeof r[1]?e[r[0].trim()]=r[1].trim():e[r[0].trim()]=r[1],r=["",""]};t.readUntil("{");for(let e=t.next();void 0!==e&&"}"!==e;e=t.next()){const o=r[0];switch(e){case'"':case"'":for(r[s]+=e+t.readUntil(e);r[s].endsWith("\\"+e);)r[s]+=t.readUntil(e);break;case":":""===o.trim()||o.includes("&")||o.includes("@")||o.includes(":")?r[s]+=e:s=1;break;case";":s=0,n();break;case"{":t.back(),r[1]=b(t),n();break;default:r[s]+=e}}return""!==r[0].trim()&&n(),e},g=new Map,y={jdom:(t,...e)=>{const s=t.join("jdom_tpl_joiner");try{if(!d.has(s)){const r=e.map((t,e)=>`jdom_tpl_obj_[${e}]`),n=new i(o(t.map(t=>t.replace(/\s+/g," ")),r)),c=a(n)[0],l=typeof c,u=JSON.stringify(c);d.set(s,t=>{if("string"===l)return m(c,t);if("object"===l){const e={},s=JSON.parse(u);return v(Object.assign(e,s),t),e}return null})}return d.get(s)(e)}catch(s){return console.error(`jdom parse error.\ncheck for mismatched brackets, tags, quotes.\n${o(t,e)}\n${s.stack||s}`),""}},css:(t,...e)=>{const s=o(t,e).trim();return g.has(s)||g.set(s,b(new i("{"+s+"}"))),g.get(s)}};"object"==typeof window&&Object.assign(window,y),t.exports&&(t.exports=y)}]); --------------------------------------------------------------------------------