This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

timeful: add .ics download to schedule event

+257 -4
+2
5pkgs/timeful/package.nix
··· 16 16 # landing page's YouTube/Vimeo embeds and GitHub star button 17 17 # - replaces the Google/Outlook/email-OTP sign-in page with GitHub OAuth 18 18 # (GET /api/auth/github, GITHUB_CLIENT_ID/GITHUB_CLIENT_SECRET) 19 + # - "Schedule" offers a .ics download next to Google Calendar/Outlook, and 20 + # scheduled-event links point at this instance instead of timeful.app 19 21 # - makes the listen address configurable via LISTEN_ADDR (upstream 20 22 # hardcodes ":3002" on all interfaces) 21 23 src = applyPatches {
+255 -4
5pkgs/timeful/self-host.patch
··· 99 99 return this.folders.sort((a, b) => { 100 100 return a.name.localeCompare(b.name) 101 101 diff --git a/frontend/src/components/schedule_overlap/ScheduleOverlap.vue b/frontend/src/components/schedule_overlap/ScheduleOverlap.vue 102 - index 1e028082..eb523062 100644 102 + index 1e028082..d7377fa3 100644 103 103 --- a/frontend/src/components/schedule_overlap/ScheduleOverlap.vue 104 104 +++ b/frontend/src/components/schedule_overlap/ScheduleOverlap.vue 105 - @@ -1250,11 +1250,7 @@ export default { 105 + @@ -1048,6 +1048,7 @@ import { 106 + isPremiumUser, 107 + prefersStartOnMonday, 108 + } from "@/utils" 109 + +import { buildIcs, downloadIcs } from "@/utils/ics_utils" 110 + import { 111 + availabilityTypes, 112 + calendarOptionsDefaults, 113 + @@ -1250,11 +1251,7 @@ export default { 106 114 ...mapState(["authUser", "overlayAvailabilitiesEnabled"]), 107 115 ...mapGetters(["isPremiumUser"]), 108 116 showAds() { ··· 115 123 }, 116 124 /** Returns the width of the right side of the calendar */ 117 125 rightSideWidth() { 126 + @@ -2076,7 +2073,7 @@ export default { 127 + } 128 + return `Tap and drag to add your "available" ${daysOrTimes} in green.` 129 + case this.states.SCHEDULE_EVENT: 130 + - return "Tap and drag on the calendar to schedule a Google Calendar event during those times." 131 + + return "Tap and drag on the calendar to schedule an event during those times." 132 + default: 133 + return "" 134 + } 135 + @@ -2092,7 +2089,7 @@ export default { 136 + } 137 + return `Click and drag to add your "available" ${daysOrTimes} in green.` 138 + case this.states.SCHEDULE_EVENT: 139 + - return "Click and drag on the calendar to schedule a Google Calendar event during those times." 140 + + return "Click and drag on the calendar to schedule an event during those times." 141 + default: 142 + return "" 143 + } 144 + @@ -3563,8 +3560,11 @@ export default { 145 + this.state = this.defaultState 146 + }, 147 + 148 + - /** Redirect user to Google Calendar to finish the creation of the event */ 149 + - confirmScheduleEvent(googleCalendar = true) { 150 + + /** 151 + + * Finish scheduling the dragged-out slot: open Google Calendar / Outlook 152 + + * prefilled, or download an .ics file. target: "google" | "outlook" | "ics" 153 + + */ 154 + + confirmScheduleEvent(target = "google") { 155 + if (!this.curScheduledEvent) return 156 + // if (!isPremiumUser(this.authUser)) { 157 + // this.showUpgradeDialog({ 158 + @@ -3615,9 +3615,27 @@ export default { 159 + const emailsString = encodeURIComponent(emails.filter(Boolean).join(",")) 160 + 161 + const eventId = this.event.shortId ?? this.event._id 162 + + const eventUrl = `${window.location.origin}/e/${eventId}` 163 + + const description = `\n\nThis event was scheduled with Timeful: ${eventUrl}` 164 + + 165 + + if (target === "ics") { 166 + + const ics = buildIcs({ 167 + + uid: `${this.event._id}-${startDate.getTime()}@${window.location.host}`, 168 + + start: startDate, 169 + + end: endDate, 170 + + summary: this.event.name, 171 + + description: description.trim(), 172 + + location: this.event.location, 173 + + url: eventUrl, 174 + + }) 175 + + const filename = `${this.event.name.replace(/[^\w-]+/g, "_") || "event"}.ics` 176 + + downloadIcs(filename, ics) 177 + + this.state = this.defaultState 178 + + return 179 + + } 180 + 181 + let url = "" 182 + - if (googleCalendar) { 183 + + if (target === "google") { 184 + // Format start and end date to be in the format required by gcal (remove -, :, and .000) 185 + const start = startDate.toISOString().replace(/([-:]|\.000)/g, "") 186 + const end = endDate.toISOString().replace(/([-:]|\.000)/g, "") 187 + @@ -3626,14 +3644,13 @@ export default { 188 + url = `https://calendar.google.com/calendar/render?action=TEMPLATE&text=${encodeURIComponent( 189 + this.event.name 190 + )}&dates=${start}/${end}&details=${encodeURIComponent( 191 + - "\n\nThis event was scheduled with Timeful: https://timeful.app/e/" 192 + - )}${eventId}&ctz=${this.curTimezone.value}&add=${emailsString}` 193 + + description 194 + + )}&ctz=${this.curTimezone.value}&add=${emailsString}` 195 + } else { 196 + url = `https://outlook.live.com/calendar/0/deeplink/compose?subject=${encodeURIComponent( 197 + this.event.name 198 + )}&body=${encodeURIComponent( 199 + - "\n\nThis event was scheduled with Timeful: https://timeful.app/e/" + 200 + - eventId 201 + + description 202 + )}&startdt=${startDate.toISOString()}&enddt=${endDate.toISOString()}&location=${encodeURIComponent( 203 + this.event.location || "" 204 + )}&path=/calendar/action/compose&timezone=${this.curTimezone.value}` 205 + diff --git a/frontend/src/components/schedule_overlap/ToolRow.vue b/frontend/src/components/schedule_overlap/ToolRow.vue 206 + index d8aaaddd..5822dd28 100644 207 + --- a/frontend/src/components/schedule_overlap/ToolRow.vue 208 + +++ b/frontend/src/components/schedule_overlap/ToolRow.vue 209 + @@ -118,7 +118,9 @@ 210 + </v-btn> 211 + </template> 212 + <v-list dense> 213 + - <v-list-item @click="(e) => $emit('confirmScheduleEvent', true)"> 214 + + <v-list-item 215 + + @click="(e) => $emit('confirmScheduleEvent', 'google')" 216 + + > 217 + <v-img 218 + src="@/assets/gcal_logo.png" 219 + class="tw-mr-2 tw-flex-none" 220 + @@ -129,7 +131,9 @@ 221 + <v-list-item-title>Google Calendar</v-list-item-title> 222 + </v-list-item-content> 223 + </v-list-item> 224 + - <v-list-item @click="(e) => $emit('confirmScheduleEvent', false)"> 225 + + <v-list-item 226 + + @click="(e) => $emit('confirmScheduleEvent', 'outlook')" 227 + + > 228 + <v-img 229 + src="@/assets/outlook_logo.svg" 230 + class="tw-mr-2 tw-flex-none" 231 + @@ -140,6 +144,14 @@ 232 + <v-list-item-title>Outlook</v-list-item-title> 233 + </v-list-item-content> 234 + </v-list-item> 235 + + <v-list-item @click="(e) => $emit('confirmScheduleEvent', 'ics')"> 236 + + <v-icon class="tw-mr-2 tw-flex-none" size="20" 237 + + >mdi-calendar-export</v-icon 238 + + > 239 + + <v-list-item-content> 240 + + <v-list-item-title>Download .ics</v-list-item-title> 241 + + </v-list-item-content> 242 + + </v-list-item> 243 + </v-list> 244 + </v-menu> 245 + </template> 118 246 diff --git a/frontend/src/main.js b/frontend/src/main.js 119 247 index 2d370f7a..9dfa971a 100644 120 248 --- a/frontend/src/main.js ··· 152 280 153 281 // Experiments 154 282 pricingPageConversion: "control", 283 + diff --git a/frontend/src/utils/ics_utils.js b/frontend/src/utils/ics_utils.js 284 + new file mode 100644 285 + index 00000000..c285bd1b 286 + --- /dev/null 287 + +++ b/frontend/src/utils/ics_utils.js 288 + @@ -0,0 +1,69 @@ 289 + +/** Builds and downloads a single-event iCalendar (RFC 5545) file. */ 290 + + 291 + +const utf8 = new TextEncoder() 292 + + 293 + +/** Escapes a TEXT property value (RFC 5545 §3.3.11) */ 294 + +const escapeText = (text) => 295 + + text 296 + + .replace(/\\/g, "\\\\") 297 + + .replace(/;/g, "\\;") 298 + + .replace(/,/g, "\\,") 299 + + .replace(/\r?\n/g, "\\n") 300 + + 301 + +/** Formats a Date as a UTC DATE-TIME, e.g. 20261020T070000Z */ 302 + +const formatDateTime = (date) => 303 + + date.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}/, "") 304 + + 305 + +/** Folds a content line so no physical line exceeds 75 octets (RFC 5545 §3.1) */ 306 + +const foldLine = (line) => { 307 + + const parts = [] 308 + + let current = "" 309 + + let currentBytes = 0 310 + + for (const char of line) { 311 + + const charBytes = utf8.encode(char).length 312 + + // continuation lines start with a space, which counts toward the limit 313 + + const limit = parts.length === 0 ? 75 : 74 314 + + if (currentBytes + charBytes > limit) { 315 + + parts.push(current) 316 + + current = "" 317 + + currentBytes = 0 318 + + } 319 + + current += char 320 + + currentBytes += charBytes 321 + + } 322 + + parts.push(current) 323 + + return parts.join("\r\n ") 324 + +} 325 + + 326 + +export const buildIcs = ({ uid, start, end, summary, description, location, url }) => { 327 + + const lines = [ 328 + + "BEGIN:VCALENDAR", 329 + + "VERSION:2.0", 330 + + "PRODID:-//timeful//self-hosted//EN", 331 + + "CALSCALE:GREGORIAN", 332 + + "METHOD:PUBLISH", 333 + + "BEGIN:VEVENT", 334 + + `UID:${uid}`, 335 + + `DTSTAMP:${formatDateTime(new Date())}`, 336 + + `DTSTART:${formatDateTime(start)}`, 337 + + `DTEND:${formatDateTime(end)}`, 338 + + `SUMMARY:${escapeText(summary)}`, 339 + + ] 340 + + if (description) lines.push(`DESCRIPTION:${escapeText(description)}`) 341 + + if (location) lines.push(`LOCATION:${escapeText(location)}`) 342 + + if (url) lines.push(`URL:${url}`) 343 + + lines.push("END:VEVENT", "END:VCALENDAR") 344 + + return lines.map(foldLine).join("\r\n") + "\r\n" 345 + +} 346 + + 347 + +export const downloadIcs = (filename, icsText) => { 348 + + const blob = new Blob([icsText], { type: "text/calendar;charset=utf-8" }) 349 + + const href = URL.createObjectURL(blob) 350 + + const a = document.createElement("a") 351 + + a.href = href 352 + + a.download = filename 353 + + document.body.appendChild(a) 354 + + a.click() 355 + + a.remove() 356 + + URL.revokeObjectURL(href) 357 + +} 155 358 diff --git a/frontend/src/views/Event.vue b/frontend/src/views/Event.vue 156 - index 81887280..79786ce3 100644 359 + index 81887280..87dba8d1 100644 157 360 --- a/frontend/src/views/Event.vue 158 361 +++ b/frontend/src/views/Event.vue 159 - @@ -638,11 +638,7 @@ export default { 362 + @@ -463,13 +463,29 @@ 363 + Cancel 364 + </v-btn> 365 + <v-spacer /> 366 + - <v-btn 367 + - :disabled="!allowScheduleEvent" 368 + - class="tw-bg-white tw-text-blue" 369 + - @click="confirmScheduleEvent" 370 + - > 371 + - Schedule 372 + - </v-btn> 373 + + <v-menu top offset-y> 374 + + <template v-slot:activator="{ on, attrs }"> 375 + + <v-btn 376 + + :disabled="!allowScheduleEvent" 377 + + class="tw-bg-white tw-text-blue" 378 + + v-bind="attrs" 379 + + v-on="on" 380 + + > 381 + + Schedule 382 + + </v-btn> 383 + + </template> 384 + + <v-list dense> 385 + + <v-list-item @click="confirmScheduleEvent('google')"> 386 + + <v-list-item-title>Google Calendar</v-list-item-title> 387 + + </v-list-item> 388 + + <v-list-item @click="confirmScheduleEvent('outlook')"> 389 + + <v-list-item-title>Outlook</v-list-item-title> 390 + + </v-list-item> 391 + + <v-list-item @click="confirmScheduleEvent('ics')"> 392 + + <v-list-item-title>Download .ics</v-list-item-title> 393 + + </v-list-item> 394 + + </v-list> 395 + + </v-menu> 396 + </template> 397 + </div> 398 + <PubliftAd 399 + @@ -638,11 +654,7 @@ export default { 160 400 ...mapState(["authUser", "events"]), 161 401 ...mapGetters(["isPremiumUser"]), 162 402 showAds() { ··· 169 409 }, 170 410 allowScheduleEvent() { 171 411 return this.scheduleOverlapComponent?.allowScheduleEvent 412 + @@ -998,8 +1010,8 @@ export default { 413 + cancelScheduleEvent() { 414 + this.scheduleOverlapComponent?.cancelScheduleEvent() 415 + }, 416 + - confirmScheduleEvent() { 417 + - this.scheduleOverlapComponent?.confirmScheduleEvent() 418 + + confirmScheduleEvent(target) { 419 + + this.scheduleOverlapComponent?.confirmScheduleEvent(target) 420 + }, 421 + 422 + highlightAvailabilityBtn() { 172 423 diff --git a/frontend/src/views/Landing.vue b/frontend/src/views/Landing.vue 173 424 index b8a50d8f..41d7521c 100644 174 425 --- a/frontend/src/views/Landing.vue