├── example.png ├── favicon.ico ├── README.md ├── style.css ├── LICENSE ├── iframe.html ├── index.html ├── script.js ├── example.ics ├── embed.css ├── embed.js ├── ical.min.js └── ical.min.js.map /example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GRA0007/modern-cal-embed/HEAD/example.png -------------------------------------------------------------------------------- /favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GRA0007/modern-cal-embed/HEAD/favicon.ico -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Modern iCal Calendar Embed 2 | 3 | Example 4 | 5 | This is a fully browser-side iCal (ics) calendar embed. It takes a link to an iCal file, and parses it and turns it into an embed. To use it, simply visit the link below and fill out the fields. 6 | 7 | https://gra0007.github.io/modern-cal-embed/ 8 | 9 | **Note:** To use iCal urls that don't specify an `Access-Control-Allow-Origin` header, like from Google calendar, you'll need to use a cors proxy like https://cors-anywhere.herokuapp.com/. 10 | 11 | You can test the embed's functionality using the following ics file url: 12 | `https://gra0007.github.io/modern-cal-embed/example.ics` 13 | -------------------------------------------------------------------------------- /style.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 20px; 3 | background: #FFF; 4 | font-family: sans-serif; 5 | } 6 | main { 7 | display: grid; 8 | width: 100%; 9 | grid-template-columns: .6fr 1.4fr; 10 | grid-gap: 20px; 11 | } 12 | iframe { 13 | border: 1px solid #777; 14 | width: 100%; 15 | margin-top: 20px; 16 | border-radius: 3px; 17 | } 18 | 19 | h1 { 20 | margin: 0; 21 | } 22 | #about { 23 | display: block; 24 | margin: 0 0 30px; 25 | } 26 | h2 { 27 | margin-top: 0; 28 | } 29 | 30 | label { 31 | display: inline-block; 32 | margin-top: 6px; 33 | } 34 | input[type=url] { 35 | width: 100%; 36 | box-sizing: border-box; 37 | } 38 | input[type=url], select { 39 | padding: 4px 6px; 40 | background-color: inherit; 41 | font: inherit; 42 | font-size: .9em; 43 | border: 1px solid #777; 44 | border-radius: 3px; 45 | } 46 | input[type=color], button { 47 | padding: 4px 6px; 48 | border: 1px solid #777; 49 | border-radius: 3px; 50 | background: none; 51 | } 52 | 53 | @media screen and (max-width: 650px) { 54 | main { 55 | display: block; 56 | } 57 | #embed { 58 | margin-top: 20px; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Benjamin Grant 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 | -------------------------------------------------------------------------------- /iframe.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Calendar Embed 5 | 6 | 7 | 8 | 9 | 10 | Calendar is loading... 11 | 12 |
13 |

Calendar title

14 | 38 |
39 | 40 |
41 | 42 |
43 | 44 |
45 |
46 | 47 | 48 |
49 |
50 |
51 | 52 | 53 | 54 | 55 | 56 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Modern iCal Calendar Embed 5 | 6 | 7 | 8 | 9 | 10 | 17 | 18 | 19 | 20 | 21 | 22 |

Modern iCal Calendar Embed

23 | Handcrafted by Ben Grant using ical.js. Visit the Github repo. 24 | 25 |
26 |
27 |

Settings

28 | 29 |
30 |

31 | 32 | 33 |
34 | 35 | 36 |
37 | 38 | 39 |
40 | 41 | 42 |
43 | 44 | 45 |
46 | 47 | 48 |

49 | 50 |
51 |

55 | 56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 | 67 |
68 | 69 | 70 | 71 |
72 |
73 | 74 | 75 | 76 | 77 | -------------------------------------------------------------------------------- /script.js: -------------------------------------------------------------------------------- 1 | const CORS_URL = 'https://cors-anywhere.herokuapp.com/'; 2 | 3 | const ical_field = document.getElementById('ical'); 4 | const show_title_field = document.getElementById('show_title'); 5 | const show_nav_field = document.getElementById('show_nav'); 6 | const show_date_field = document.getElementById('show_date'); 7 | const show_details_field = document.getElementById('show_details'); 8 | const show_view_field = document.getElementById('show_view'); 9 | const monday_start_field = document.getElementById('monday_start'); 10 | const default_view_field = document.getElementById('default_view'); 11 | const color_field = document.getElementById('color'); 12 | const colorbg_field = document.getElementById('colorbg'); 13 | const colortxt_field = document.getElementById('colortxt'); 14 | const colorsectxt_field = document.getElementById('colorsecondarytxt'); 15 | const embed_field = document.getElementById('embed_link'); 16 | const copy_button = document.getElementById('copy_url'); 17 | const cors_button = document.getElementById('add_cors'); 18 | const iframe = document.getElementById('iframe'); 19 | 20 | // Defaults 21 | let ical = 'https://gra0007.github.io/modern-cal-embed/example.ics'; 22 | let show_title = 1; 23 | let show_nav = 1; 24 | let show_date = 1; 25 | let show_details = 0; 26 | let show_view = 1; 27 | let monday_start = 0; 28 | let default_view = 0; 29 | let color = '#1A73E8'; 30 | let colorbg = '#FFFFFF'; 31 | let colortxt = '#000000'; 32 | let colorsecondarytxt = '#FFFFFF'; 33 | 34 | // Reload iframe with new params 35 | function refresh() { 36 | let embed = `${document.URL.substr(0,document.URL.lastIndexOf('/'))}/iframe.html?ical=${encodeURIComponent(ical)}&title=${show_title}&nav=${show_nav}&date=${show_date}&view=${show_view}&details=${show_details}&monstart=${monday_start}&dview=${default_view}&color=${encodeURIComponent(color)}&colorbg=${encodeURIComponent(colorbg)}&colortxt=${encodeURIComponent(colortxt)}&colorsecondarytxt=${encodeURIComponent(colorsecondarytxt)}`; 37 | embed_field.value = embed; 38 | iframe.src = embed; 39 | } 40 | 41 | ical_field.addEventListener('change', () => { 42 | ical = ical_field.value; 43 | refresh(); 44 | }); 45 | 46 | show_title_field.addEventListener('change', () => { 47 | show_title = show_title_field.checked ? 1 : 0; 48 | refresh(); 49 | }); 50 | 51 | show_nav_field.addEventListener('change', () => { 52 | show_nav = show_nav_field.checked ? 1 : 0; 53 | refresh(); 54 | }); 55 | 56 | show_date_field.addEventListener('change', () => { 57 | show_date = show_date_field.checked ? 1 : 0; 58 | refresh(); 59 | }); 60 | 61 | show_details_field.addEventListener('change', () => { 62 | show_details = show_details_field.checked ? 1 : 0; 63 | refresh(); 64 | }); 65 | 66 | show_view_field.addEventListener('change', () => { 67 | show_view = show_view_field.checked ? 1 : 0; 68 | refresh(); 69 | }); 70 | 71 | monday_start_field.addEventListener('change', () => { 72 | monday_start = monday_start_field.checked ? 1 : 0; 73 | refresh(); 74 | }); 75 | 76 | color_field.addEventListener('change', () => { 77 | color = color_field.value; 78 | refresh(); 79 | }); 80 | colorbg_field.addEventListener('change', () => { 81 | colorbg = colorbg_field.value; 82 | refresh(); 83 | }); 84 | colortxt_field.addEventListener('change', () => { 85 | colortxt = colortxt_field.value; 86 | refresh(); 87 | }); 88 | colorsectxt_field.addEventListener('change', () => { 89 | colorsecondarytxt = colorsectxt_field.value; 90 | refresh(); 91 | }); 92 | 93 | default_view_field.addEventListener('change', () => { 94 | default_view = default_view_field.value; 95 | refresh(); 96 | }); 97 | 98 | copy_button.addEventListener('click', () => { 99 | embed_field.select(); 100 | embed_field.setSelectionRange(0, 99999); 101 | document.execCommand("copy"); 102 | copy_button.innerHTML = 'Copied!'; 103 | window.setTimeout(() => { 104 | copy_button.innerHTML = 'Copy'; 105 | }, 1000); 106 | }); 107 | 108 | cors_button.addEventListener('click', () => { 109 | if (!ical_field.value.startsWith(CORS_URL)) { 110 | ical_field.value = CORS_URL + ical_field.value; 111 | } 112 | ical = ical_field.value; 113 | refresh(); 114 | }); 115 | 116 | refresh(); 117 | -------------------------------------------------------------------------------- /example.ics: -------------------------------------------------------------------------------- 1 | BEGIN:VCALENDAR 2 | PRODID:-//Google Inc//Google Calendar 70.9054//EN 3 | VERSION:2.0 4 | CALSCALE:GREGORIAN 5 | METHOD:PUBLISH 6 | X-WR-CALNAME:Test calendar 7 | X-WR-TIMEZONE:Australia/Sydney 8 | X-WR-CALDESC:A test ics file for the modern ical embed 9 | BEGIN:VTIMEZONE 10 | TZID:Australia/Sydney 11 | X-LIC-LOCATION:Australia/Sydney 12 | BEGIN:STANDARD 13 | TZOFFSETFROM:+1100 14 | TZOFFSETTO:+1000 15 | TZNAME:AEST 16 | DTSTART:19700405T030000 17 | RRULE:FREQ=YEARLY;BYMONTH=4;BYDAY=1SU 18 | END:STANDARD 19 | BEGIN:DAYLIGHT 20 | TZOFFSETFROM:+1000 21 | TZOFFSETTO:+1100 22 | TZNAME:AEDT 23 | DTSTART:19701004T020000 24 | RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=1SU 25 | END:DAYLIGHT 26 | END:VTIMEZONE 27 | BEGIN:VEVENT 28 | DTSTART;TZID=Australia/Sydney:20200814T130000 29 | DTEND;TZID=Australia/Sydney:20200814T133000 30 | RRULE:FREQ=WEEKLY;BYDAY=FR 31 | DTSTAMP:20200814T014029Z 32 | UID:299lkegd0cprc9djor49tqoplp@google.com 33 | CREATED:20200814T013731Z 34 | DESCRIPTION: 35 | LAST-MODIFIED:20200814T013731Z 36 | LOCATION: 37 | SEQUENCE:0 38 | STATUS:CONFIRMED 39 | SUMMARY:Call with Mum 40 | TRANSP:OPAQUE 41 | END:VEVENT 42 | BEGIN:VEVENT 43 | DTSTART;TZID=Australia/Sydney:20200814T090000 44 | DTEND;TZID=Australia/Sydney:20200814T103000 45 | RRULE:FREQ=WEEKLY;BYDAY=FR 46 | DTSTAMP:20200814T014029Z 47 | UID:2fce9lrif30kcg6c0evk8pdbo7@google.com 48 | CREATED:20200814T013547Z 49 | DESCRIPTION:Remember to ask about molar 50 | LAST-MODIFIED:20200814T013547Z 51 | LOCATION:Dentist 52 | SEQUENCE:0 53 | STATUS:CONFIRMED 54 | SUMMARY:Dentist appointment 55 | TRANSP:OPAQUE 56 | END:VEVENT 57 | BEGIN:VEVENT 58 | DTSTART;TZID=Australia/Sydney:20200815T110000 59 | DTEND;TZID=Australia/Sydney:20200815T160000 60 | RRULE:FREQ=WEEKLY;BYDAY=SA 61 | DTSTAMP:20200814T014029Z 62 | UID:14i0k7r0it0qcclfj2q3m0kas2@google.com 63 | CREATED:20200814T013418Z 64 | DESCRIPTION: 65 | LAST-MODIFIED:20200814T013418Z 66 | LOCATION:Hanging Rock\, 139 S Rock Rd\, Woodend VIC 3442\, Australia 67 | SEQUENCE:0 68 | STATUS:CONFIRMED 69 | SUMMARY:Picnic with family 70 | TRANSP:OPAQUE 71 | END:VEVENT 72 | BEGIN:VEVENT 73 | DTSTART;TZID=Australia/Sydney:20200813T143000 74 | DTEND;TZID=Australia/Sydney:20200813T153000 75 | RRULE:FREQ=WEEKLY;BYDAY=TH 76 | DTSTAMP:20200814T014029Z 77 | UID:4q6bv44o5fcvgkeui6fc50ar15@google.com 78 | CREATED:20200814T013312Z 79 | DESCRIPTION: 80 | LAST-MODIFIED:20200814T013312Z 81 | LOCATION:https://example.com/call 82 | SEQUENCE:0 83 | STATUS:CONFIRMED 84 | SUMMARY:Online conference call 85 | TRANSP:OPAQUE 86 | END:VEVENT 87 | BEGIN:VEVENT 88 | DTSTART;VALUE=DATE:20200812 89 | DTEND;VALUE=DATE:20200813 90 | RRULE:FREQ=WEEKLY;BYDAY=WE 91 | DTSTAMP:20200814T014029Z 92 | UID:4l5tfbpustle12roqskimjac3f@google.com 93 | CREATED:20200814T013210Z 94 | DESCRIPTION: 95 | LAST-MODIFIED:20200814T013210Z 96 | LOCATION: 97 | SEQUENCE:0 98 | STATUS:CONFIRMED 99 | SUMMARY:Katie's Birthday 100 | TRANSP:TRANSPARENT 101 | END:VEVENT 102 | BEGIN:VEVENT 103 | DTSTART;TZID=Australia/Sydney:20200811T173000 104 | DTEND;TZID=Australia/Sydney:20200811T173000 105 | RRULE:FREQ=WEEKLY;BYDAY=TU 106 | DTSTAMP:20200814T014029Z 107 | UID:0fjc486didjnjle7jeip2ttent@google.com 108 | CREATED:20200814T013108Z 109 | DESCRIPTION: 110 | LAST-MODIFIED:20200814T013108Z 111 | LOCATION: 112 | SEQUENCE:0 113 | STATUS:CONFIRMED 114 | SUMMARY:Reminder to check mailbox 115 | TRANSP:OPAQUE 116 | END:VEVENT 117 | BEGIN:VEVENT 118 | DTSTART;TZID=Australia/Sydney:20200813T170000 119 | DTEND;TZID=Australia/Sydney:20200814T130000 120 | RRULE:FREQ=WEEKLY;BYDAY=TH 121 | DTSTAMP:20200814T014029Z 122 | UID:3fonamt1sj3fgs5bar49fc0p13@google.com 123 | CREATED:20200814T013027Z 124 | DESCRIPTION: 125 | LAST-MODIFIED:20200814T013027Z 126 | LOCATION:Sam's house 127 | SEQUENCE:0 128 | STATUS:CONFIRMED 129 | SUMMARY:Jake sleepover 130 | TRANSP:OPAQUE 131 | END:VEVENT 132 | BEGIN:VEVENT 133 | DTSTART;VALUE=DATE:20200811 134 | DTEND;VALUE=DATE:20200812 135 | RRULE:FREQ=WEEKLY;BYDAY=TU 136 | DTSTAMP:20200814T014029Z 137 | UID:2et88f5oc7vi3pvbdfkeb7h3se@google.com 138 | CREATED:20200814T012859Z 139 | DESCRIPTION: 140 | LAST-MODIFIED:20200814T012859Z 141 | LOCATION: 142 | SEQUENCE:0 143 | STATUS:CONFIRMED 144 | SUMMARY:Casual clothes day at work 145 | TRANSP:TRANSPARENT 146 | END:VEVENT 147 | BEGIN:VEVENT 148 | DTSTART;TZID=Australia/Sydney:20200810T120000 149 | DTEND;TZID=Australia/Sydney:20200810T130000 150 | RRULE:FREQ=WEEKLY;BYDAY=MO 151 | DTSTAMP:20200814T014029Z 152 | UID:32ldbigccsv7e9jurlndg7q1ac@google.com 153 | CREATED:20200814T012824Z 154 | DESCRIPTION:Don't forget to bring a present! 155 | LAST-MODIFIED:20200814T012824Z 156 | LOCATION:A fancy restaurant 157 | SEQUENCE:0 158 | STATUS:CONFIRMED 159 | SUMMARY:Lunch with Sarah 160 | TRANSP:OPAQUE 161 | END:VEVENT 162 | BEGIN:VEVENT 163 | DTSTART;TZID=Australia/Sydney:20200810T100000 164 | DTEND;TZID=Australia/Sydney:20200810T113000 165 | RRULE:FREQ=WEEKLY;BYDAY=MO 166 | DTSTAMP:20200814T014029Z 167 | UID:01t42lh9bot1kuq7gl3dhgtsrt@google.com 168 | CREATED:20200814T012712Z 169 | DESCRIPTION: 170 | LAST-MODIFIED:20200814T012723Z 171 | LOCATION:The office 172 | SEQUENCE:1 173 | STATUS:CONFIRMED 174 | SUMMARY:Morning meeting 175 | TRANSP:OPAQUE 176 | END:VEVENT 177 | END:VCALENDAR 178 | -------------------------------------------------------------------------------- /embed.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --background-color: #FFF; 3 | --dark-gray: #777; 4 | --border-color: #AAA; 5 | --text-color: #000; 6 | --theme-color: #1A73E8; 7 | --theme-text-color: #FFF; 8 | } 9 | 10 | body { 11 | margin: 0; 12 | background-color: var(--background-color); 13 | color: var(--text-color); 14 | font-family: sans-serif; 15 | min-height: 100vh; 16 | } 17 | #loading { 18 | display: flex; 19 | position: absolute; 20 | z-index: 1000; 21 | background-color: var(--background-color); 22 | color: var(--dark-gray); 23 | top: 0; 24 | left: 0; 25 | height: 100%; 26 | width: 100%; 27 | align-items: center; 28 | justify-content: center; 29 | text-align: center; 30 | } 31 | #loading.done { 32 | display: none; 33 | } 34 | 35 | #top { 36 | position: sticky; 37 | top: 0; 38 | left: 0; 39 | right: 0; 40 | background-color: var(--background-color); 41 | padding: 4px; 42 | z-index: 20; 43 | } 44 | 45 | #title { 46 | margin: 0; 47 | margin-bottom: 6px; 48 | font-size: 1.3em; 49 | } 50 | 51 | nav { 52 | display: flex; 53 | align-items: center; 54 | } 55 | nav .spacer { 56 | flex: 1; 57 | } 58 | 59 | nav button, nav select { 60 | font: inherit; 61 | color: inherit; 62 | background: 0; 63 | font-size: 0.8em; 64 | border: 1px solid var(--border-color); 65 | border-radius: 3px; 66 | padding: 4px 8px; 67 | } 68 | nav #arrows { 69 | display: flex; 70 | align-items: center; 71 | margin: 0 10px; 72 | } 73 | nav #arrows button { 74 | border: 0; 75 | padding: 0; 76 | border-radius: 100px; 77 | height: 30px; 78 | width: 30px; 79 | display: block; 80 | cursor: pointer; 81 | transition: background-color .2s; 82 | outline: 0; 83 | } 84 | nav #arrows button:hover { 85 | background-color: rgba(0,0,0,.1); 86 | } 87 | nav #arrows button:focus { 88 | background-color: rgba(0,0,0,.2); 89 | } 90 | nav #arrows button svg { 91 | height: 24px; 92 | width: 24px; 93 | padding: 3px; 94 | } 95 | 96 | nav #date_label { 97 | display: flex; 98 | align-items: center; 99 | position: relative; 100 | } 101 | nav #date_label:hover, 102 | nav #date_label:focus, 103 | nav #date_label:focus-within { 104 | text-decoration: underline; 105 | } 106 | nav #date_label svg { 107 | height: 24px; 108 | width: 24px; 109 | padding-left: 4px; 110 | } 111 | nav #date_dropdown { 112 | position: absolute; 113 | top: 100%; 114 | background-color: var(--background-color); 115 | width: 100%; 116 | left: 0; 117 | opacity: 0; 118 | pointer-events: none; 119 | } 120 | nav #date_label:focus #date_dropdown, 121 | nav #date_label:focus-within #date_dropdown { 122 | opacity: 1; 123 | pointer-events: all; 124 | } 125 | nav #date { 126 | width: 100%; 127 | } 128 | 129 | #month.hidden, #agenda.hidden { 130 | display: none; 131 | } 132 | 133 | #agenda { 134 | table-layout: fixed; 135 | border-collapse: collapse; 136 | width: calc(100% - 8px); 137 | box-sizing: border-box; 138 | margin: 4px; 139 | } 140 | #emptystate td { 141 | text-align: left !important; 142 | color: var(--dark-gray); 143 | } 144 | #agenda .emptyday { 145 | padding-left: 5px; 146 | vertical-align: middle; 147 | font-style: italic; 148 | color: var(--dark-gray); 149 | } 150 | #agenda tr td { 151 | padding-top: 14px; 152 | vertical-align: top; 153 | } 154 | #agenda tr td:first-child { 155 | width: 60px; 156 | text-align: center; 157 | } 158 | #agenda .dayname, 159 | #agenda .month { 160 | display: block; 161 | font-size: .6em; 162 | font-weight: bold; 163 | letter-spacing: .1em; 164 | color: var(--dark-gray); 165 | } 166 | #agenda .day { 167 | display: flex; 168 | font-size: 1.2em; 169 | align-items: center; 170 | justify-content: center; 171 | } 172 | #agenda .today .dayname, 173 | #agenda .today .month { 174 | color: var(--theme-color); 175 | } 176 | #agenda .today .day span { 177 | display: flex; 178 | align-items: center; 179 | justify-content: center; 180 | color: var(--theme-text-color); 181 | background-color: var(--theme-color); 182 | height: 1.6em; 183 | width: 1.6em; 184 | border-radius: 100px; 185 | margin: 4px 0; 186 | } 187 | 188 | #agenda .event { 189 | background-color: var(--theme-color); 190 | color: var(--theme-text-color); 191 | margin-bottom: 8px; 192 | border-radius: 3px; 193 | margin-left: 5px; 194 | } 195 | #agenda .event a { 196 | color: inherit; 197 | } 198 | #agenda .event .summary { 199 | padding: 10px; 200 | cursor: pointer; 201 | } 202 | #agenda .event.open.always .summary { 203 | cursor: initial; 204 | } 205 | #agenda .event .name { 206 | display: block; 207 | font-weight: bold; 208 | } 209 | #agenda .event:hover .name, 210 | #agenda .event:focus .name, 211 | #agenda .event.open .name { 212 | text-decoration: underline; 213 | } 214 | #agenda .event.open.always .name { 215 | text-decoration: none; 216 | } 217 | #agenda .event.open .time { 218 | display: none; 219 | } 220 | #agenda .event .details { 221 | display: none; 222 | padding: 0 10px 10px; 223 | } 224 | #agenda .event.open .details { 225 | margin-top: 4px; 226 | display: block; 227 | } 228 | 229 | #agenda .indicator { 230 | height: 2px; 231 | width: 100%; 232 | background-color: var(--text-color); 233 | margin-bottom: 8px; 234 | position: relative; 235 | } 236 | #agenda .indicator:before { 237 | content: ''; 238 | position: absolute; 239 | left: 0; 240 | top: -4px; 241 | height: 10px; 242 | width: 10px; 243 | background-color: var(--text-color); 244 | border-radius: 100px; 245 | } 246 | 247 | 248 | #month { 249 | table-layout: fixed; 250 | border-collapse: collapse; 251 | width: calc(100% - 8px); 252 | box-sizing: border-box; 253 | margin: 4px; 254 | } 255 | #month td { 256 | border: 1px solid var(--border-color); 257 | vertical-align: top; 258 | overflow: hidden; 259 | } 260 | #month .labels td { 261 | border: 0; 262 | text-align: center; 263 | color: var(--dark-gray); 264 | font-weight: bold; 265 | height: 1px; 266 | } 267 | #month td .date { 268 | text-align: center; 269 | display: block; 270 | color: var(--dark-gray); 271 | margin-top: 1px; 272 | } 273 | #month td .date.current { 274 | color: var(--text-color); 275 | } 276 | #month td .date span { 277 | display: inline-flex; 278 | align-items: center; 279 | justify-content: center; 280 | height: 1.7em; 281 | width: 1.7em; 282 | border-radius: 100px; 283 | } 284 | #month td .date.today span { 285 | background-color: var(--theme-color); 286 | color: var(--theme-text-color); 287 | } 288 | #month .event { 289 | display: block; 290 | white-space: nowrap; 291 | cursor: pointer; 292 | user-select: none; 293 | font-size: .9em; 294 | background-color: var(--theme-color); 295 | color: var(--theme-text-color); 296 | margin: 2px 0; 297 | box-sizing: border-box; 298 | padding: 2px 4px; 299 | border-radius: 3px; 300 | } 301 | #month .past .event { 302 | opacity: .6; 303 | } 304 | 305 | #monthDetails { 306 | position: fixed; 307 | top: 0; 308 | left: 0; 309 | right: 0; 310 | bottom: 0; 311 | pointer-events: none; 312 | z-index: 5000; 313 | display: none; 314 | overflow-y: auto; 315 | } 316 | #monthDetails.shown { 317 | display: flex; 318 | } 319 | #monthDetails .dialog { 320 | background-color: var(--background-color); 321 | pointer-events: all; 322 | max-width: 60%; 323 | box-shadow: 0 5px 30px rgba(0,0,0,.6); 324 | border-radius: 10px; 325 | box-sizing: border-box; 326 | padding: 40px 20px 20px; 327 | position: relative; 328 | margin: auto; 329 | } 330 | #monthDetails button { 331 | position: absolute; 332 | top: 14px; 333 | right: 14px; 334 | border: 0; 335 | padding: 0; 336 | border-radius: 100px; 337 | height: 30px; 338 | width: 30px; 339 | display: block; 340 | cursor: pointer; 341 | transition: background-color .2s; 342 | outline: 0; 343 | background-color: transparent; 344 | } 345 | #monthDetails button:hover { 346 | background-color: rgba(0,0,0,.1); 347 | } 348 | #monthDetails button:focus { 349 | background-color: rgba(0,0,0,.2); 350 | } 351 | #monthDetails button svg { 352 | height: 24px; 353 | width: 24px; 354 | padding: 3px; 355 | } 356 | #monthDetails .summary { 357 | display: block; 358 | font-size: 1.6em; 359 | margin-bottom: 14px; 360 | } 361 | -------------------------------------------------------------------------------- /embed.js: -------------------------------------------------------------------------------- 1 | const AGENDA_DAYS = 20; 2 | const DAYS_OF_WEEK = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; 3 | const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']; 4 | let ampm = (h) => (h < 12 || h === 24) ? "am" : "pm"; 5 | 6 | const url = new URL(window.location.href); 7 | const loading = document.getElementById('loading'); 8 | 9 | const ical = url.searchParams.get('ical'); 10 | let show_title = url.searchParams.get('title') || 1; 11 | const show_nav = url.searchParams.get('nav') || 1; 12 | const show_date = url.searchParams.get('date') || 1; 13 | const show_details = url.searchParams.get('details') || 0; 14 | const show_view = url.searchParams.get('view') || 1; 15 | const default_view = url.searchParams.get('dview') || 0; 16 | const monday_start = url.searchParams.get('monstart') || 0; 17 | const color = url.searchParams.get('color') || '#1A73E8'; 18 | const colorBG = url.searchParams.get('colorbg') || '#FFFFFF'; 19 | const colorText = url.searchParams.get('colortxt') || '#000000'; 20 | const colorThemeText = url.searchParams.get('colorsecondarytxt') || '#FFFFFF'; 21 | 22 | let today = new Date(); 23 | today.setHours(0,0,0,0); 24 | let selectedDay = new Date(today.valueOf()); 25 | let selectedView = default_view; 26 | 27 | function getHumanDate(date) { 28 | return `${date.getFullYear()}-${(date.getMonth()+1).toString().padStart(2,0)}-${date.getDate().toString().padStart(2,0)}`; 29 | } 30 | 31 | function createDateCell(date, todayd = false) { 32 | let day = date.getDay(); 33 | let dateM = date.getDate(); 34 | let month = date.getMonth(); 35 | let dateCell = document.createElement('td'); 36 | dateCell.tabIndex = '-1'; 37 | dateCell.dataset.date = getHumanDate(date); 38 | dateCell.onfocus = () => { 39 | selectDay(getHumanDate(date), false) 40 | }; 41 | if (todayd) { 42 | dateCell.className = 'today'; 43 | } 44 | let dayEl = document.createElement('span'); 45 | dayEl.className = 'dayname'; 46 | dayEl.appendChild(document.createTextNode(DAYS_OF_WEEK[day].substring(0,3).toUpperCase())); 47 | dateCell.appendChild(dayEl); 48 | let dateEl = document.createElement('span'); 49 | dateEl.className = 'day'; 50 | let dateSpan = document.createElement('span'); 51 | dateSpan.appendChild(document.createTextNode(dateM)); 52 | dateEl.appendChild(dateSpan); 53 | dateCell.appendChild(dateEl); 54 | let monthEl = document.createElement('span'); 55 | monthEl.className = 'month'; 56 | monthEl.appendChild(document.createTextNode(MONTHS[month].substring(0,3).toUpperCase())); 57 | dateCell.appendChild(monthEl); 58 | return dateCell; 59 | } 60 | 61 | function selectDay(date, focus = true, events = null) { 62 | let newSelection = new Date(date + 'T00:00'); 63 | let newMonth = false; 64 | if (selectedDay.getMonth() != newSelection.getMonth() && events != null) { 65 | renderMonth(events, newSelection); 66 | newMonth = true; 67 | } 68 | 69 | selectedDay = newSelection; 70 | 71 | document.querySelector('#date_label span').innerHTML = `${DAYS_OF_WEEK[selectedDay.getDay()]}, ${MONTHS[selectedDay.getMonth()]} ${selectedDay.getDate()}`; 72 | document.getElementById('date').value = getHumanDate(selectedDay); 73 | 74 | let selectedElement = document.querySelector(`td[data-date='${getHumanDate(selectedDay)}']`); 75 | if (selectedElement && (focus || newMonth)) { 76 | selectedElement.focus(); 77 | } 78 | } 79 | 80 | function setView(newView, events) { 81 | selectedView = newView; 82 | if (selectedView == 1) { 83 | renderMonth(events); 84 | document.getElementById('agenda').classList.add('hidden'); 85 | } else { 86 | renderAgenda(events); 87 | document.getElementById('month').classList.add('hidden'); 88 | } 89 | } 90 | 91 | function eventDetails(event) { 92 | let startTime = `${(event.startDate.getHours() % 12) || 12}:${event.startDate.getMinutes() < 10 ? '0' : ''}${event.startDate.getMinutes()}`; 93 | let endTime = `${(event.endDate.getHours() % 12) || 12}:${event.endDate.getMinutes() < 10 ? '0' : ''}${event.endDate.getMinutes()}`; 94 | let startM = ampm(event.startDate.getHours()); 95 | let endM = ampm(event.endDate.getHours()); 96 | 97 | let eDetails = document.createElement('div'); 98 | eDetails.className = 'details'; 99 | 100 | let whenLabel = document.createElement('strong'); 101 | whenLabel.appendChild(document.createTextNode('When: ')); 102 | let when = document.createElement('span'); 103 | when.className = 'when'; 104 | let whenText = `${DAYS_OF_WEEK[event.startDate.getDay()].substring(0,3)}, ${MONTHS[event.startDate.getMonth()]} ${event.startDate.getDate()}, ${startTime}${startM} - ${endTime}${endM}`; 105 | if (event.days == 1 && event.allDay) { 106 | whenText = `${DAYS_OF_WEEK[event.startDate.getDay()]}, ${MONTHS[event.startDate.getMonth()].substring(0,3)} ${event.startDate.getDate()}, ${event.startDate.getFullYear()}`; 107 | } else if (event.days % 1 == 0 && event.allDay) { 108 | let newEnd = new Date(event.endDate.valueOf()); 109 | newEnd.setDate(newEnd.getDate()-1); 110 | whenText = `${MONTHS[event.startDate.getMonth()].substring(0,3)} ${event.startDate.getDate()} - ${MONTHS[newEnd.getMonth()].substring(0,3)} ${newEnd.getDate()}, ${event.startDate.getFullYear()}`; 111 | } else if (event.days > 1) { 112 | whenText = `${MONTHS[event.startDate.getMonth()]} ${event.startDate.getDate()}, ${startTime}${startM} - ${MONTHS[event.endDate.getMonth()]} ${event.endDate.getDate()}, ${endTime}${endM}`; 113 | } 114 | 115 | when.appendChild(document.createTextNode(whenText)); 116 | eDetails.appendChild(whenLabel); 117 | eDetails.appendChild(when); 118 | 119 | if (event.location != '') { 120 | eDetails.appendChild(document.createElement('br')); 121 | let whereLabel = document.createElement('strong'); 122 | whereLabel.appendChild(document.createTextNode('Where: ')); 123 | let where = document.createElement('span'); 124 | where.className = 'where'; 125 | let whereText = document.createTextNode(event.location); 126 | if (event.location.startsWith('http')) { 127 | whereText = document.createElement('a'); 128 | whereText.href = event.location; 129 | whereText.target = '_blank'; 130 | whereText.appendChild(document.createTextNode(event.location)); 131 | } 132 | where.appendChild(whereText); 133 | eDetails.appendChild(whereLabel); 134 | eDetails.appendChild(where); 135 | } 136 | 137 | if (event.description != '') { 138 | eDetails.appendChild(document.createElement('br')); 139 | let descLabel = document.createElement('strong'); 140 | descLabel.appendChild(document.createTextNode('Description: ')); 141 | let desc = document.createElement('span'); 142 | desc.className = 'description'; 143 | desc.innerHTML = event.description; 144 | eDetails.appendChild(descLabel); 145 | eDetails.appendChild(desc); 146 | } 147 | 148 | return eDetails; 149 | } 150 | 151 | function renderAgenda(events) { 152 | // Filter after today 153 | events = events.filter((e) => { 154 | let end = new Date(e.endDate.valueOf()); 155 | end.setHours(0,0,0,0); 156 | return end >= today; 157 | }); 158 | 159 | // Create elements 160 | let days = []; 161 | let row; 162 | let column; 163 | let prevDay = null; 164 | let indicator = document.createElement('div'); 165 | indicator.className = 'indicator'; 166 | let nowDate = new Date(); 167 | let now = `${(nowDate.getHours() % 12) || 12}:${nowDate.getMinutes() < 10 ? '0' : ''}${nowDate.getMinutes()}`; 168 | let nowM = ampm(nowDate.getHours()); 169 | indicator.title = `${now} ${nowM}`; 170 | let indicatorset = false; 171 | let todayHasEvents = false; 172 | for (let i = 0; i < (events.length < AGENDA_DAYS ? events.length : AGENDA_DAYS); i++) { 173 | let tomorrow = new Date(today.valueOf()); 174 | tomorrow.setDate(tomorrow.getDate() + 1); 175 | if (events[i].startDate > tomorrow && !todayHasEvents) { 176 | todayHasEvents = true; 177 | row = document.createElement('tr'); 178 | row.appendChild(createDateCell( 179 | events[i].startDate, 180 | true 181 | )); 182 | column = document.createElement('td'); 183 | column.className = 'emptyday'; 184 | column.appendChild(document.createTextNode('No events today')); 185 | row.appendChild(column); 186 | days.push(row); 187 | } 188 | if (prevDay != events[i].startDate.toDateString()) { 189 | prevDay = events[i].startDate.toDateString(); 190 | row = document.createElement('tr'); 191 | 192 | let curDay = new Date(events[i].startDate.valueOf()); 193 | curDay.setHours(0,0,0,0); 194 | if (curDay.getTime() == today.getTime()) { 195 | todayHasEvents = true; 196 | } 197 | 198 | row.appendChild(createDateCell( 199 | events[i].startDate, 200 | curDay.getTime() == today.getTime() 201 | )); 202 | column = document.createElement('td'); 203 | } 204 | 205 | // Indicator 206 | let eventDay = new Date(events[i].endDate.valueOf()); 207 | eventDay.setHours(0,0,0,0); 208 | if (nowDate < events[i].endDate && !indicatorset && today.getTime() == eventDay.getTime()) { 209 | column.appendChild(indicator); 210 | indicatorset = true; 211 | } 212 | 213 | let event = document.createElement('div'); 214 | event.className = 'event'; 215 | 216 | let summary = document.createElement('div'); 217 | summary.className = 'summary'; 218 | if (show_details == 0) { 219 | summary.tabIndex = '0'; 220 | summary.onkeypress = (e) => { 221 | if (e.keyCode === 13) { 222 | event.classList.toggle('open'); 223 | } 224 | }; 225 | summary.onclick = () => event.classList.toggle('open'); 226 | } else { 227 | event.className = 'event open always'; 228 | } 229 | 230 | let eName = document.createElement('span'); 231 | eName.className = 'name'; 232 | eName.appendChild(document.createTextNode(events[i].name)); 233 | summary.appendChild(eName); 234 | 235 | let startTime = `${(events[i].startDate.getHours() % 12) || 12}:${events[i].startDate.getMinutes() < 10 ? '0' : ''}${events[i].startDate.getMinutes()}`; 236 | let endTime = `${(events[i].endDate.getHours() % 12) || 12}:${events[i].endDate.getMinutes() < 10 ? '0' : ''}${events[i].endDate.getMinutes()}`; 237 | let startM = ampm(events[i].startDate.getHours()); 238 | let endM = ampm(events[i].endDate.getHours()); 239 | 240 | if (!events[i].allDay) { 241 | let eTime = document.createElement('span'); 242 | eTime.className = 'time'; 243 | let timeText = `${startTime} ${startM == endM ? '' : startM} - ${endTime} ${endM}`; 244 | if (events[i].days === 0) { 245 | timeText = `${startTime} ${startM}`; 246 | } else if (events[i].days > 1 && !events[i].allDay) { 247 | timeText = `${MONTHS[events[i].startDate.getMonth()]} ${events[i].startDate.getDate()}, ${startTime}${startM} - ${MONTHS[events[i].endDate.getMonth()]} ${events[i].endDate.getDate()}, ${endTime}${endM}`; 248 | } 249 | eTime.appendChild(document.createTextNode(timeText)); 250 | summary.appendChild(eTime); 251 | } 252 | event.appendChild(summary); 253 | 254 | event.appendChild(eventDetails(events[i])); 255 | 256 | column.appendChild(event); 257 | 258 | if (events[i].endDate < nowDate && today.getTime() == eventDay.getTime()) { 259 | column.appendChild(indicator); 260 | } 261 | 262 | if (i+1 == events.length || events[i].startDate.toDateString() != events[i+1].startDate.toDateString()) { 263 | row.appendChild(column); 264 | days.push(row); 265 | } 266 | } 267 | 268 | let agenda = document.getElementById('agenda'); 269 | agenda.innerHTML = ''; 270 | agenda.classList.remove('hidden'); 271 | for (let i = 0; i < days.length; i++) { 272 | agenda.appendChild(days[i]); 273 | } 274 | 275 | // Empty state 276 | if (events.length == 0) { 277 | let emptystate = document.createElement('tr'); 278 | emptystate.id = 'emptystate'; 279 | let emptydata = document.createElement('td'); 280 | emptydata.appendChild(document.createTextNode('No upcoming events')); 281 | emptystate.appendChild(emptydata); 282 | agenda.appendChild(emptystate); 283 | } 284 | } 285 | 286 | function showMonthDetails(event) { 287 | let details = document.getElementById('monthDetails'); 288 | 289 | document.querySelector('#monthDetails .summary').innerHTML = event.name; 290 | 291 | document.querySelector('#monthDetails .details').innerHTML = ''; 292 | document.querySelector('#monthDetails .details').appendChild(eventDetails(event)); 293 | 294 | details.classList.add('shown'); 295 | } 296 | 297 | function renderMonth(events, fromDay = new Date(today.valueOf())) { 298 | let monthStartDate = new Date(fromDay.getFullYear(), fromDay.getMonth(), 1); 299 | let monthEndDate = new Date(fromDay.getFullYear(), fromDay.getMonth() + 1, 0); 300 | while (monthStartDate.getDay() != monday_start) { 301 | monthStartDate.setDate(monthStartDate.getDate() - 1); 302 | } 303 | while (monthEndDate.getDay() != (monday_start == 1 ? 0 : 6)) { 304 | monthEndDate.setDate(monthEndDate.getDate() + 1); 305 | } 306 | let days = (monthEndDate - monthStartDate) / (24 * 60 * 60 * 1000) + 1; 307 | let weeks = days/7; 308 | 309 | let rows = []; 310 | 311 | // Labels 312 | let labelRow = document.createElement('tr'); 313 | labelRow.className = 'labels'; 314 | for (let i = 0; i < 7; i++) { 315 | let label = document.createElement('td'); 316 | let n = i + parseInt(monday_start); 317 | label.appendChild(document.createTextNode(DAYS_OF_WEEK[(n == 7 ? 0 : n)].substring(0,3))); 318 | labelRow.appendChild(label); 319 | } 320 | rows.push(labelRow); 321 | 322 | let day = new Date(monthStartDate.valueOf()); 323 | for (let i = 0; i < weeks; i++) { 324 | let weekRow = document.createElement('tr'); 325 | for (let j = 0; j < 7; j++) { 326 | let dayCell = document.createElement('td'); 327 | dayCell.dataset.date = getHumanDate(day); 328 | dayCell.onfocus = () => { 329 | selectDay(dayCell.dataset.date, false, events); 330 | }; 331 | dayCell.tabIndex = '-1'; 332 | if (day < today) { 333 | dayCell.className = 'past'; 334 | } 335 | let dateEl = document.createElement('span'); 336 | dateEl.classList.add('date'); 337 | if (day.getMonth() == fromDay.getMonth()) { 338 | dateEl.classList.add('current'); 339 | } 340 | if (getHumanDate(day) == getHumanDate(today)) { 341 | dateEl.classList.add('today'); 342 | } 343 | let dateText = document.createElement('span'); 344 | dateText.appendChild(document.createTextNode(day.getDate())); 345 | dateEl.appendChild(dateText); 346 | dayCell.appendChild(dateEl); 347 | 348 | let dayEvents = events.filter((e) => getHumanDate(e.startDate) == getHumanDate(day)); 349 | 350 | for (let e = 0; e < dayEvents.length; e++) { 351 | let event = document.createElement('div'); 352 | event.className = 'event'; 353 | event.tabIndex = '0'; 354 | event.appendChild(document.createTextNode(dayEvents[e].name)); 355 | event.onkeypress = (e) => { 356 | if (e.keyCode === 13) { 357 | showMonthDetails(dayEvents[e]); 358 | } 359 | }; 360 | event.onclick = () => {showMonthDetails(dayEvents[e])}; 361 | dayCell.appendChild(event); 362 | } 363 | weekRow.appendChild(dayCell); 364 | 365 | day.setDate(day.getDate()+1); 366 | } 367 | rows.push(weekRow); 368 | } 369 | 370 | let topHeight = 0; 371 | let topEl = document.getElementById('top'); 372 | if (topEl.style.display != 'none') { 373 | topHeight = topEl.clientHeight; 374 | } 375 | let monthEl = document.getElementById('month'); 376 | monthEl.style.height = `calc(100vh - ${topHeight+8}px)`; 377 | monthEl.innerHTML = ''; 378 | monthEl.classList.remove('hidden'); 379 | for (let i = 0; i < rows.length; i++) { 380 | monthEl.appendChild(rows[i]); 381 | } 382 | } 383 | 384 | function renderCalendar(meta, events) { 385 | // Sort events 386 | events.sort((a,b) => a.startDate - b.startDate); 387 | 388 | // Title 389 | if (show_title == 1) { 390 | show_title = meta.calname != null; 391 | } 392 | if (show_title == 1) { 393 | document.getElementById('title').innerHTML = meta.calname; 394 | } else { 395 | document.getElementById('title').style.display = 'none'; 396 | } 397 | 398 | // Nav 399 | let btn_today = document.getElementById('btn_today'); 400 | let arrows = document.getElementById('arrows'); 401 | btn_today.onclick = () => { 402 | // Scroll to today 403 | selectDay(getHumanDate(today), true, events); 404 | }; 405 | document.getElementById('btn_prev').onclick = () => { 406 | let prevDay = new Date(selectedDay.valueOf()); 407 | prevDay.setDate(prevDay.getDate() - 1); 408 | selectDay(getHumanDate(prevDay), true, events); 409 | }; 410 | document.getElementById('btn_next').onclick = () => { 411 | let prevDay = new Date(selectedDay.valueOf()); 412 | prevDay.setDate(prevDay.getDate() + 1); 413 | selectDay(getHumanDate(prevDay), true, events); 414 | }; 415 | if (show_nav == 0) { 416 | btn_today.style.display = 'none'; 417 | arrows.style.display = 'none'; 418 | } 419 | 420 | // View 421 | let view = document.getElementById('view'); 422 | view.value = default_view; 423 | view.onchange = () => { 424 | setView(view.value, events); 425 | }; 426 | if (show_view == 0) { 427 | view.style.display = 'none'; 428 | } 429 | 430 | // Date 431 | let date_label = document.getElementById('date_label'); 432 | let date_input = document.getElementById('date'); 433 | document.querySelector('#date_label span').innerHTML = `${DAYS_OF_WEEK[selectedDay.getDay()]}, ${MONTHS[selectedDay.getMonth()]} ${selectedDay.getDate()}`; 434 | date_input.value = getHumanDate(selectedDay); 435 | date_input.onchange = () => { 436 | selectDay(date_input.value, true, events); 437 | }; 438 | if (show_date == 0) { 439 | date_label.style.display = 'none'; 440 | } 441 | 442 | // Remove nav element 443 | if (show_title == 0 && show_nav == 0 && show_date == 0 && show_view == 0) { 444 | document.getElementById('top').style.display = 'none'; 445 | } 446 | 447 | // Colors 448 | document.documentElement.style.setProperty('--theme-color', color); 449 | document.documentElement.style.setProperty('--text-color', colorText); 450 | document.documentElement.style.setProperty('--background-color', colorBG); 451 | document.documentElement.style.setProperty('--theme-text-color', colorThemeText); 452 | 453 | setView(selectedView, events); 454 | 455 | loading.style.display = 'none'; 456 | } 457 | 458 | function parseCalendar(data) { 459 | let jCal = ICAL.parse(data); 460 | let comp = new ICAL.Component(jCal); 461 | 462 | const meta = { 463 | calname: comp.getFirstPropertyValue('x-wr-calname'), 464 | timezone: new ICAL.Timezone(comp.getFirstSubcomponent('vtimezone')).tzid, 465 | caldesc: comp.getFirstPropertyValue('x-wr-caldesc') 466 | }; 467 | 468 | let eventData = comp.getAllSubcomponents('vevent'); 469 | let events = []; 470 | 471 | // Copy event data to custom array 472 | for (let i = 0; i < eventData.length; i++) { 473 | let event = new ICAL.Event(eventData[i]); 474 | let duration = event.endDate.subtractDate(event.startDate); 475 | events.push({ 476 | uid: event.uid, 477 | name: event.summary, 478 | location: event.location, 479 | description: event.description, 480 | startDate: event.startDate.toJSDate(), 481 | endDate: event.endDate.toJSDate(), 482 | allDay: event.startDate.isDate, 483 | days: (duration.toSeconds()/86400) 484 | }); 485 | if (event.isRecurring()) { 486 | let expand = new ICAL.RecurExpansion({ 487 | component: eventData[i], 488 | dtstart: event.startDate 489 | }); 490 | 491 | let j = 0; 492 | let next; 493 | while (j < 10 && (next = expand.next())) { 494 | if (j > 0) { 495 | let endDate = next.clone(); 496 | endDate.addDuration(duration); 497 | events.push({ 498 | uid: event.uid, 499 | name: event.summary, 500 | location: event.location, 501 | description: event.description, 502 | startDate: next.toJSDate(), 503 | endDate: endDate.toJSDate(), 504 | allDay: event.startDate.isDate, 505 | days: (duration.toSeconds()/86400) 506 | }); 507 | } 508 | j++; 509 | } 510 | } 511 | } 512 | renderCalendar(meta, events); 513 | } 514 | 515 | if (ical) { 516 | fetch(ical).then((response) => { 517 | response.text().then((text) => { 518 | parseCalendar(text); 519 | }); 520 | }).catch((e) => { 521 | console.error(e); 522 | loading.innerHTML = "Error: iCal URL doesn't exist or isn't valid

iCal links (like those from Google calendar) will need to use a cors proxy"; 523 | }); 524 | } else { 525 | loading.innerHTML = "Error: no iCal URL provided"; 526 | } 527 | -------------------------------------------------------------------------------- /ical.min.js: -------------------------------------------------------------------------------- 1 | "object"==typeof module?ICAL=module.exports:"object"!=typeof ICAL&&(this.ICAL={}),ICAL.foldLength=75,ICAL.newLineChar="\r\n",ICAL.helpers={updateTimezones:function(t){var e,r,n,i,a,s;if(!t||"vcalendar"!==t.name)return t;for(e=t.getAllSubcomponents(),r=[],n={},a=0;a>18&63,r=a>>12&63,n=a>>6&63,i=63&a,c[u++]=s.charAt(e)+s.charAt(r)+s.charAt(n)+s.charAt(i),o>16&255,r=s>>8&255,n=255&s,c[h++]=64==i?String.fromCharCode(e):64==a?String.fromCharCode(e,r):String.fromCharCode(e,r,n),u=this.changes.length)break}var s=this.changes[n];if(s.utcOffset-s.prevUtcOffset<0&&0=this.changes.length?this.changes.length-1:e},_ensureCoverage:function(t){if(-1==ICAL.Timezone._minimumExpansionYear){var e=ICAL.Time.now();ICAL.Timezone._minimumExpansionYear=e.year}var r=t;if(rICAL.Timezone.MAX_YEAR&&(r=ICAL.Timezone.MAX_YEAR),!this.changes.length||this.expandedUntilYeart)&&l);)n.year=l.year,n.month=l.month,n.day=l.day,n.hour=l.hour,n.minute=l.minute,n.second=l.second,n.isDate=l.isDate,ICAL.Timezone.adjust_change(n,0,0,0,-n.prevUtcOffset),r.push(n)}}else(n=s()).year=i.year,n.month=i.month,n.day=i.day,n.hour=i.hour,n.minute=i.minute,n.second=i.second,ICAL.Timezone.adjust_change(n,0,0,0,-n.prevUtcOffset),r.push(n);return r},toString:function(){return this.tznames?this.tznames:this.tzid}},ICAL.Timezone._compare_change_fn=function(t,e){return t.yeare.year?1:t.monthe.month?1:t.daye.day?1:t.houre.hour?1:t.minutee.minute?1:t.seconde.second?1:0},ICAL.Timezone.convert_time=function(t,e,r){if(t.isDate||e.tzid==r.tzid||e==ICAL.Timezone.localTimezone||r==ICAL.Timezone.localTimezone)return t.zone=r,t;var n=e.utcOffset(t);return t.adjust(0,0,0,-n),n=r.utcOffset(t),t.adjust(0,0,0,n),null},ICAL.Timezone.fromData=function(t){return(new ICAL.Timezone).fromData(t)},ICAL.Timezone.utcTimezone=ICAL.Timezone.fromData({tzid:"UTC"}),ICAL.Timezone.localTimezone=ICAL.Timezone.fromData({tzid:"floating"}),ICAL.Timezone.adjust_change=function(t,e,r,n,i){return ICAL.Time.prototype.adjust.call(t,e,r,n,i,t)},ICAL.Timezone._minimumExpansionYear=-1,ICAL.Timezone.MAX_YEAR=2035,ICAL.Timezone.EXTRA_COVERAGE=5}(),ICAL.TimezoneService=function(){var r,t={get count(){return Object.keys(r).length},reset:function(){r=Object.create(null);var t=ICAL.Timezone.utcTimezone;r.Z=t,r.UTC=t,r.GMT=t},has:function(t){return!!r[t]},get:function(t){return r[t]},register:function(t,e){if(t instanceof ICAL.Component&&"vtimezone"===t.name&&(t=(e=new ICAL.Timezone(t)).tzid),!(e instanceof ICAL.Timezone))throw new TypeError("timezone must be ICAL.Timezone or ICAL.Component");r[t]=e},remove:function(t){return delete r[t]}};return t.reset(),t}(),function(){function t(e){Object.defineProperty(ICAL.Time.prototype,e,{get:function(){return this._pendingNormalization&&(this._normalize(),this._pendingNormalization=!1),this._time[e]},set:function(t){return"isDate"===e&&t&&!this._time.isDate&&this.adjust(0,0,0,0),this._cachedUnixTime=null,this._pendingNormalization=!0,this._time[e]=t}})}ICAL.Time=function(t,e){var r=(this.wrappedJSObject=this)._time=Object.create(null);r.year=0,r.month=1,r.day=1,r.hour=0,r.minute=0,r.second=0,r.isDate=!1,this.fromData(t,e)},ICAL.Time._dowCache={},ICAL.Time._wnCache={},ICAL.Time.prototype={icalclass:"icaltime",_cachedUnixTime:null,get icaltype(){return this.isDate?"date":"date-time"},zone:null,_pendingNormalization:!1,clone:function(){return new ICAL.Time(this._time,this.zone)},reset:function(){this.fromData(ICAL.Time.epochTime),this.zone=ICAL.Timezone.utcTimezone},resetTo:function(t,e,r,n,i,a,s){this.fromData({year:t,month:e,day:r,hour:n,minute:i,second:a,zone:s})},fromJSDate:function(t,e){return t?e?(this.zone=ICAL.Timezone.utcTimezone,this.year=t.getUTCFullYear(),this.month=t.getUTCMonth()+1,this.day=t.getUTCDate(),this.hour=t.getUTCHours(),this.minute=t.getUTCMinutes(),this.second=t.getUTCSeconds()):(this.zone=ICAL.Timezone.localTimezone,this.year=t.getFullYear(),this.month=t.getMonth()+1,this.day=t.getDate(),this.hour=t.getHours(),this.minute=t.getMinutes(),this.second=t.getSeconds()):this.reset(),this._cachedUnixTime=null,this},fromData:function(t,e){if(t)for(var r in t)if(Object.prototype.hasOwnProperty.call(t,r)){if("icaltype"===r)continue;this[r]=t[r]}if(e&&(this.zone=e),!t||"isDate"in t?t&&"isDate"in t&&(this.isDate=t.isDate):this.isDate=!("hour"in t),t&&"timezone"in t){var n=ICAL.TimezoneService.get(t.timezone);this.zone=n||ICAL.Timezone.localTimezone}return t&&"zone"in t&&(this.zone=t.zone),this.zone||(this.zone=ICAL.Timezone.localTimezone),this._cachedUnixTime=null,this},dayOfWeek:function(t){var e=t||ICAL.Time.SUNDAY,r=(this.year<<12)+(this.month<<8)+(this.day<<3)+e;if(r in ICAL.Time._dowCache)return ICAL.Time._dowCache[r];var n=this.day,i=this.month+(this.month<3?12:0),a=this.year-(this.month<3?1:0),s=n+a+ICAL.helpers.trunc(26*(i+1)/10)+ICAL.helpers.trunc(a/4);return s=((s+=6*ICAL.helpers.trunc(a/100)+ICAL.helpers.trunc(a/400))+7-e)%7+1,ICAL.Time._dowCache[r]=s},dayOfYear:function(){var t=ICAL.Time.isLeapYear(this.year)?1:0;return ICAL.Time.daysInYearPassedMonth[t][this.month-1]+this.day},startOfWeek:function(t){var e=t||ICAL.Time.SUNDAY,r=this.clone();return r.day-=(this.dayOfWeek()+7-e)%7,r.isDate=!0,r.hour=0,r.minute=0,r.second=0,r},endOfWeek:function(t){var e=t||ICAL.Time.SUNDAY,r=this.clone();return r.day+=(7-this.dayOfWeek()+e-ICAL.Time.SUNDAY)%7,r.isDate=!0,r.hour=0,r.minute=0,r.second=0,r},startOfMonth:function(){var t=this.clone();return t.day=1,t.isDate=!0,t.hour=0,t.minute=0,t.second=0,t},endOfMonth:function(){var t=this.clone();return t.day=ICAL.Time.daysInMonth(t.month,t.year),t.isDate=!0,t.hour=0,t.minute=0,t.second=0,t},startOfYear:function(){var t=this.clone();return t.day=1,t.month=1,t.isDate=!0,t.hour=0,t.minute=0,t.second=0,t},endOfYear:function(){var t=this.clone();return t.day=31,t.month=12,t.isDate=!0,t.hour=0,t.minute=0,t.second=0,t},startDoyWeek:function(t){var e=t||ICAL.Time.SUNDAY,r=this.dayOfWeek()-e;return r<0&&(r+=7),this.dayOfYear()-r},getDominicalLetter:function(){return ICAL.Time.getDominicalLetter(this.year)},nthWeekDay:function(t,e){var r,n=ICAL.Time.daysInMonth(this.month,this.year),i=e,a=0,s=this.clone();if(0<=i){s.day=1,0!=i&&i--,a=s.day;var o=t-s.dayOfWeek();o<0&&(o+=7),a+=o,a-=t,r=t}else{s.day=n,i++,(r=s.dayOfWeek()-t)<0&&(r+=7),r=n-r}return a+(r+=7*i)},isNthWeekDay:function(t,e){var r=this.dayOfWeek();return 0===e&&r===t||this.nthWeekDay(t,e)===this.day},weekNumber:function(t){var e,r=(this.year<<12)+(this.month<<8)+(this.day<<3)+t;if(r in ICAL.Time._wnCache)return ICAL.Time._wnCache[r];var n=this.clone();n.isDate=!0;var i=this.year;12==n.month&&25ICAL.Time.daysInYearPassedMonth[a][12])return a=ICAL.Time.isLeapYear(r)?1:0,n-=ICAL.Time.daysInYearPassedMonth[a][12],r++,ICAL.Time.fromDayOfYear(n,r);i.year=r,i.isDate=!0;for(var s=11;0<=s;s--)if(n>ICAL.Time.daysInYearPassedMonth[a][s]){i.month=s+1,i.day=n-ICAL.Time.daysInYearPassedMonth[a][s];break}return i.auto_normalize=!0,i},ICAL.Time.fromStringv2=function(t){return new ICAL.Time({year:parseInt(t.substr(0,4),10),month:parseInt(t.substr(5,2),10),day:parseInt(t.substr(8,2),10),isDate:!0})},ICAL.Time.fromDateString=function(t){return new ICAL.Time({year:ICAL.helpers.strictParseInt(t.substr(0,4)),month:ICAL.helpers.strictParseInt(t.substr(5,2)),day:ICAL.helpers.strictParseInt(t.substr(8,2)),isDate:!0})},ICAL.Time.fromDateTimeString=function(t,e){if(t.length<19)throw new Error('invalid date-time value: "'+t+'"');var r;return t[19]&&"Z"===t[19]?r="Z":e&&(r=e.getParameter("tzid")),new ICAL.Time({year:ICAL.helpers.strictParseInt(t.substr(0,4)),month:ICAL.helpers.strictParseInt(t.substr(5,2)),day:ICAL.helpers.strictParseInt(t.substr(8,2)),hour:ICAL.helpers.strictParseInt(t.substr(11,2)),minute:ICAL.helpers.strictParseInt(t.substr(14,2)),second:ICAL.helpers.strictParseInt(t.substr(17,2)),timezone:r})},ICAL.Time.fromString=function(t,e){return 10ICAL.Time.THURSDAY&&(r.day+=7),i>ICAL.Time.THURSDAY&&(r.day-=7),r.day-=n-i,r},ICAL.Time.getDominicalLetter=function(t){var e="GFEDCBA",r=(t+(t/4|0)+(t/400|0)-(t/100|0)-1)%7;return ICAL.Time.isLeapYear(t)?e[(6+r)%7]+e[r]:e[r]},ICAL.Time.epochTime=ICAL.Time.fromData({year:1970,month:1,day:1,hour:0,minute:0,second:0,isDate:!1,timezone:"Z"}),ICAL.Time._cmp_attr=function(t,e,r){return t[r]>e[r]?1:t[r] '+e);if(void 0!==r&&rs||0==this.last.day)throw new Error("Malformed values in BYDAY part")}else if(this.has_by_data("BYMONTHDAY")&&this.last.day<0){s=ICAL.Time.daysInMonth(this.last.month,this.last.year);this.last.day=s+this.last.day+1}},next:function(){var t,e=this.last?this.last.clone():null;if(this.rule.count&&this.occurrence_number>=this.rule.count||this.rule.until&&0i)){if(n<0)n=i+(n+1);else if(0===n)continue;-1===a.indexOf(n)&&a.push(n)}return a.sort(function(t,e){return t-e})},_byDayAndMonthDay:function(t){var e,r,n,i,a=this.by_data.BYDAY,s=0,o=a.length,u=0,h=this,c=this.last.day;function l(){for(i=ICAL.Time.daysInMonth(h.last.month,h.last.year),e=h.normalizeByMonthDayRules(h.last.year,h.last.month,h.by_data.BYMONTHDAY),n=e.length;e[s]<=c&&(!t||e[s]!=c)&&s=this.by_data.BYMONTHDAY.length&&(this.by_indices.BYMONTHDAY=0,this.increment_month());e=ICAL.Time.daysInMonth(this.last.month,this.last.year);(a=this.by_data.BYMONTHDAY[this.by_indices.BYMONTHDAY])<0&&(a=e+a+1),ee?t=0:this.last.day=this.by_data.BYMONTHDAY[0]}return t},next_weekday_by_week:function(){var t=0;if(0==this.next_hour())return t;if(!this.has_by_data("BYDAY"))return 1;for(;;){var e=new ICAL.Time;this.by_indices.BYDAY++,this.by_indices.BYDAY==Object.keys(this.by_data.BYDAY).length&&(this.by_indices.BYDAY=0,t=1);var r=this.by_data.BYDAY[this.by_indices.BYDAY],n=this.ruleDayOfWeek(r)[1];(n-=this.rule.wkst)<0&&(n+=7),e.year=this.last.year,e.month=this.last.month,e.day=this.last.day;var i=e.startDoyWeek(this.rule.wkst);if(!(n+i<1)||t){var a=ICAL.Time.fromDayOfYear(i+n,this.last.year);return this.last.year=a.year,this.last.month=a.month,this.last.day=a.day,t}}},next_year:function(){if(0==this.next_hour())return 0;if(++this.days_index==this.days.length)for(this.days_index=0;this.increment_year(this.rule.interval),this.expand_year_days(this.last.year),0==this.days.length;);return this._nextByYearDay(),1},_nextByYearDay:function(){var t=this.days[this.days_index],e=this.last.year;t<1&&(t+=1,e+=1);var r=ICAL.Time.fromDayOfYear(t,e);this.last.day=r.day,this.last.month=r.month},ruleDayOfWeek:function(t,e){var r=t.match(/([+-]?[0-9])?(MO|TU|WE|TH|FR|SA|SU)/);return r?[parseInt(r[1]||0,10),t=ICAL.Recur.icalDayToNumericDay(r[2],e)]:[0,0]},next_generic:function(t,e,r,n,i){var a=t in this.by_data,s=this.rule.freq==e,o=0;if(i&&0==this[i]())return o;if(a){this.by_indices[t]++;this.by_indices[t];var u=this.by_data[t];this.by_indices[t]==u.length&&(this.by_indices[t]=0,o=1),this.last[r]=u[this.by_indices[t]]}else s&&this["increment_"+r](this.rule.interval);return a&&o&&s&&this["increment_"+n](1),o},increment_monthday:function(t){for(var e=0;er&&(this.last.day-=r,this.increment_month())}},increment_month:function(){if(this.last.day=1,this.has_by_data("BYMONTH"))this.by_indices.BYMONTH++,this.by_indices.BYMONTH==this.by_data.BYMONTH.length&&(this.by_indices.BYMONTH=0,this.increment_year(1)),this.last.month=this.by_data.BYMONTH[this.by_indices.BYMONTH];else{"MONTHLY"==this.rule.freq?this.last.month+=this.rule.interval:this.last.month++,this.last.month--;var t=ICAL.helpers.trunc(this.last.month/12);this.last.month%=12,this.last.month++,0!=t&&this.increment_year(t)}},increment_year:function(t){this.last.year+=t},increment_generic:function(t,e,r,n){this.last[e]+=t;var i=ICAL.helpers.trunc(this.last[e]/r);this.last[e]%=r,0!=i&&this["increment_"+n](i)},has_by_data:function(t){return t in this.rule.parts},expand_year_days:function(t){var e=new ICAL.Time;this.days=[];var r={},n=["BYDAY","BYWEEKNO","BYMONTHDAY","BYMONTH","BYYEARDAY"];for(var i in n)if(n.hasOwnProperty(i)){var a=n[i];a in this.rule.parts&&(r[a]=this.rule.parts[a])}if("BYMONTH"in r&&"BYWEEKNO"in r){var s=1,o={};e.year=t,e.isDate=!0;for(var u=0;ue[0]?1:e[0]>t[0]?-1:0}return t.prototype={THISANDFUTURE:"THISANDFUTURE",exceptions:null,strictExceptions:!1,relateException:function(t){if(this.isRecurrenceException())throw new Error("cannot relate exception to exceptions");if(t instanceof ICAL.Component&&(t=new ICAL.Event(t)),this.strictExceptions&&t.uid!==this.uid)throw new Error("attempted to relate unrelated exception");var e=t.recurrenceId.toString();if((this.exceptions[e]=t).modifiesFuture()){var r=[t.recurrenceId.toUnixTime(),e],n=ICAL.helpers.binsearchInsert(this.rangeExceptions,r,i);this.rangeExceptions.splice(n,0,r)}},modifiesFuture:function(){return!!this.component.hasProperty("recurrence-id")&&this.component.getFirstProperty("recurrence-id").getParameter("range")===this.THISANDFUTURE},findRangeException:function(t){if(!this.rangeExceptions.length)return null;var e=t.toUnixTime(),r=ICAL.helpers.binsearchInsert(this.rangeExceptions,[e],i);if(--r<0)return null;var n=this.rangeExceptions[r];return e