Skip to main content

Design Doc: Add to Calendar (superco-consumer)

ShippedAuthors: @baruchiro Β· Bar LevyClickUp: CU-86caajc1eRepo: superco-consumer
As-built documentation

This is a documentation + decision record for a shipped feature, not a forward build plan β€” there is no PRD (related_prd is empty). It captures the current master behavior and the decisions that got us here. Where this doc and the earlier Confluence write-up differ, this doc reflects the merged code on master.

TL;DR​

The event page in superco-consumer shows an "Add to my calendar" modal with four buttons: Google Calendar, Office 365, iCal Download (Apple / Outlook), and Outlook Web. After a long series of timezone bugs on the Sonoma rollout, one principle now anchors the design: emit a plain UTC instant and let each client localize it. The .ics download serializes strictly in UTC (…Z, no TZID) so even old desktop Outlook builds render the correct time, and the Office 365 button (PR #1971, merged) does the same β€” outlook.office.com with UTC startdt/enddt and no ctz β€” so it works for both personal and business / Office 365 accounts. The only holdover is the legacy outlook.live.com "Outlook Web" button, whose times are still corrected client-side by 50-outlook-fix.js using the viewer's browser timezone; the Office 365 button exists precisely to route around that hack.

1. Where the feature lives​

File (superco-consumer)Responsibility
models/View/GroupsPageViewModel.php β†’ getCalendarLinks()Builds the modal's buttons as [label, icon, url-or-ics-content, openInNewTab]. Entries with an empty label are dropped (array_filter), so the rendered set is label-driven.
components/export/ICalendarBuilder.phpSpatie Generator producing the .ics (base64 data: URI) for the download button. generate() emits times in UTC (Z) via a toUtc() helper. Legacy buildGroupEvent() (the deprecated /site/ics route) still contains the old offset hacks.
components/export/OutlookOfficeBuilder.phpSpatie Generator for the outlook.office.com web deeplink (PR #1971). Emits UTC (Z) startdt/enddt, no ctz, no path.
assets/webpack/src/js/doc-ready/50-outlook-fix.jsClient-side hack that rewrites the outlook.live.com deeplink startdt/enddt into the viewer's browser-local wall time. Still active β€” it corrects the legacy "Outlook Web" button, which remains.
migrations/m260617_120000_calendar_options_luma.php (#1965)Seeds the Luma-style labels (Google Calendar / iCal Download (Apple / Outlook) / Outlook Web). A separate migration in #1971 seeds calendar_office365_text (Office 365).

Both web-link builders implement Spatie's Generator interface, so a single Link object feeds Google, the ICS download, and the Outlook deeplinks consistently.

2. What each button does​

Four entries are built in getCalendarLinks(). The value column is the literal label key in code and its current seeded value.

Button (label)LogoTargetTime encodingNotes
Google Calendar (calendar_google_text)google-calendar.png$link->google() β†’ calendar.google.com/calendar/render?action=TEMPLATE&dates=… (new tab)Spatie sets the timezone; Google honors itWorks everywhere
Office 365 (calendar_office365_text)365-calendar.png$outlookLink->formatWith(new OutlookOfficeBuilder()) β†’ outlook.office.com/calendar/deeplink/compose?rru=addevent&subject=…&startdt=…Z&enddt=…Z&location=…&body=… (new tab)UTC Z, no ctz, no pathWorks for personal and business accounts; no browser-timezone JS needed
iCal Download (Apple / Outlook) (calendar_apple_text)apple-calendar.png$icsContent β€” a data:text/calendar;…;base64,… URI β†’ downloads an .icsUTC Z via ICalendarBuilder::generate()Renamed from "Apple" (subtask 86ca9j2va, #1965) β€” the file is not Apple-specific. The single ICS download
Outlook Web (calendar_weboffice_text)outlook-calendar.png$outlookLink->webOutlook() β†’ outlook.live.com/calendar/deeplink/compose?…&path=/calendar/action/compose (new tab)UTC start/end, then rewritten to the viewer's browser-local wall time by 50-outlook-fix.jsoutlook.live.com authenticates personal Microsoft accounts only

Two Link objects feed these: $link (plain-text description + the event home URL appended) drives Google and the ICS download; $outlookLink (an HTML body so the home URL renders as a clickable anchor β€” see decision 6) drives both the Office 365 and the Outlook Web deeplinks.

The Office 365 button replaced the old "Outlook 365" ICS entry. Before #1971 the second slot was a redundant ICS download (label calendar_outlook365_text) that emitted the same $icsContent as the Apple button. #1971 repurposed that slot β€” same 365-calendar.png logo, new calendar_office365_text label β€” for the outlook.office.com web link, so there is now exactly one ICS download (the iCal button) plus two distinct Microsoft web links (Office 365 = office.com, Outlook Web = live.com).

3. Supported clients​

ClientHow it is servedWhy
Apple Calendar (macOS / iOS).ics download with UTC Z timesApple converts the UTC instant to the device's local time correctly
Old desktop Outlook.ics download with UTC Z timesOld Outlook builds don't resolve IANA TZIDs (e.g. America/Los_Angeles) without a VTIMEZONE block, so a plain UTC instant β€” which every client converts β€” is the safe encoding
Office 365 / business Outlook (outlook.office.com)the Office 365 web button, UTC Z deeplinkA single web link that works for personal and business accounts; no JS correction
Individual / personal Outlook (outlook.live.com / Outlook.com)the Outlook Web button + 50-outlook-fix.jsThe outlook.live.com deeplink only authenticates personal Microsoft accounts
Google Calendar$link->google() render URLTimezone carried in the URL

4. Decisions & rationale​

Decision 1 β€” ICS times are strictly UTC (…Z, no TZID), to support old clients​

Serialize DTSTART/DTEND as a UTC instant (e.g. 20260610T180000Z) instead of local time + TZID. Implemented in ICalendarBuilder::generate() via toUtc(), with the rationale in the method docblock:

// components/export/ICalendarBuilder.php
'DTSTART' => $this->toUtc($link->from),
// "Old Outlook builds don't resolve IANA TZIDs (e.g. America/Los_Angeles) without
// a VTIMEZONE block, so serialize as UTC (Z suffix) which every client converts."

Why: old Outlook desktop builds cannot resolve IANA TZIDs without an accompanying VTIMEZONE block, so a UTC instant β€” which every client converts to local time β€” is the most compatible encoding.

OutlookOfficeBuilder (PR #1971) sends startdt/enddt converted to UTC (…Z) and omits ctz entirely (it also drops the unnecessary path param). This unifies the whole feature on one rule: emit UTC, let the client localize.

// components/export/OutlookOfficeBuilder.php
private const BASE_URL = 'https://outlook.office.com/calendar/deeplink/compose';
private const DATE_TIME_FORMAT = 'Y-m-d\TH:i:s\Z'; // UTC instant
// parameters: rru=addevent, subject, startdt (UTC), enddt (UTC), location, body

Why (owner-confirmed on PR #1971): "Emit the event time as UTC (Z) and don't pass a non-UTC ctz… the customer's ctz=America/Los_Angeles was knowledge-sharing, not a binding spec." Calendars reliably convert UTCβ†’local, while older Outlook clients mishandle a non-UTC ctz. UTC is also the multi-tenant-safe choice β€” correct for every community without per-community timezone logic in the link.

Supersedes the earlier plan. An earlier cut of this work (and the literal ticket request) sent naive local startdt/enddt plus ctz=<community-timezone> derived from community->getTimeZone(). That was dropped in favor of UTC-no-ctz β€” see Β§7 (Alternatives considered).

Decision 3 β€” build a fresh Office 365 generator rather than trust the old code​

Rather than adapt the existing Outlook code, a fresh OutlookOfficeBuilder was written (same Spatie Generator pattern as ICalendarBuilder, different URL + query string) and slotted into the modal in place of the old "Outlook 365" ICS-download entry, reusing the 365-calendar.png logo and a new calendar_office365_text label. The legacy outlook.live.com "Outlook Web" button was left in place (and 50-outlook-fix.js with it).

Why (product decision, Baruch): "add a new button for Office 365… I can't trust [the existing code] because we have many issues with the timezone. I prefer doing the same work like the outlook.com, but with the different query string and url for office. You can take the label and logo, if exists."

Decision 4 β€” the legacy outlook.live.com button uses the viewer's browser timezone​

The "Outlook Web" button points at outlook.live.com, which interprets the supplied start/end in the account/browser zone. 50-outlook-fix.js therefore rewrites startdt/enddt from UTC to the viewer's browser-local wall time:

// assets/webpack/src/js/doc-ready/50-outlook-fix.js
linkUrl.searchParams.set('startdt', toLocalIsoWithoutTz(startUtc));
linkUrl.searchParams.set('enddt', toLocalIsoWithoutTz(endUtc));

Why it is a holdover, not the target: this browser-timezone correction is the source of the recurring timezone problems and only works for personal accounts. The Office 365 button (decision 2) was added to route around it; the JS stays only because the outlook.live.com button stays.

Decision 5 β€” rename "Apple" β†’ "iCal" (the file is not Apple-specific)​

Subtask 86ca9j2va renamed the calendar_apple_text button to "iCal Download (Apple / Outlook)" (migration #1965). The .ics download is not Apple-specific: on a non-Mac device the OS opens it in the default calendar app (usually Outlook), so the label now reflects what actually happens.

Decision 6 β€” description & location formatting​

Per subtasks 86ca3mzjc and 86ca6d294, confirmed in getCalendarLinks():

  • Location no longer carries the online-event URL for any calendar β€” address is the physical location text only (the URL rendered badly in Google/Outlook location fields).
  • The event link moves to the end of the description for all calendars.
  • For the Outlook deeplinks specifically, the body is HTML so the link is clickable β€” the home URL is appended as an <a> anchor on the $outlookLink description (this $outlookLink feeds both the Office 365 and Outlook Web buttons):
->description($this->group->description . '<br><a href="' . $encodedHomeUrl . '">' . $encodedHomeUrl . '</a>')

5. How we got here (bug history)​

The decisions above settled the umbrella production bug 86c9ryv0v ("ICS exports are 1 hour ahead") and its subtasks:

  1. Wall-clock-as-UTC. The legacy Apple path (/site/ics β†’ ICalendarBuilder::buildGroupEvent()) used an $adjustDt() helper that shifted the wall-clock by the community offset but left the zone as UTC, so local time was emitted as if it were UTC β€” every viewer was off by their local-vs-community offset (the "11:00 AM shows as 2:30 AM" reports). A device-specific iPhone -1 hour block and a +2 hours "Sonoma fix" end-time made it worse and inconsistent across regions.
  2. PR #1937 (merged) repointed the Apple button to Spatie $link->ics(), which emitted the correct instant as DTSTART;TZID=America/Los_Angeles:….
  3. Client QA then reported that older Outlook couldn't read TZID=America/Los_Angeles; subtask 86ca7wcvq tried TZID=Pacific Standard Time (the Windows zone id).
  4. Final (ICS): rather than juggle TZID dialects, the ICS now emits a plain UTC Z instant (current ICalendarBuilder::generate()), matching Sonoma's own feed (DTSTART:20260501T130000Z) and validated in Google / Apple / Outlook.
  5. Office 365 button (86caajc1e / PR #1971, merged β€” commit f888144): the outlook.office.com web button shipped, replacing the old Outlook 365 ICS entry and closing the personal-vs-business gap left by the outlook.live.com button β€” also on UTC Z.

6. QA validation snapshot​

Point-in-time QA from subtask 86ca3mzjc (Sonoma event 260168456) β€” the snapshot that drove the follow-up Outlook fix (86ca6d294):

CalendarHoursDescriptionLocation
Googleβœ… understands timezoneβœ… link at endβœ… no URL
Outlook❌ UTC time (at the time)❌ one lineβœ… no URL
Appleβœ… correct localβœ… link at endβœ… no URL

The two Outlook ❌ items (UTC time on save, non-clickable link) are exactly what subtask 86ca6d294 addressed β€” clickable HTML link in the body (decision 6) and correct time on save.

7. Alternatives considered​

  • ctz=<community-timezone> + naive local times (the earlier #1971 plan and the literal ticket request) β†’ superseded by UTC-no-ctz (decision 2): older Outlook mishandles a non-UTC ctz, and UTC needs no per-community logic.
  • TZID=America/Los_Angeles (PR #1937's initial output) β†’ abandoned: older Outlook can't resolve the IANA id without a VTIMEZONE block.
  • TZID=Pacific Standard Time (Windows zone id, subtask 86ca7wcvq) β†’ abandoned in favor of a plain UTC instant rather than maintaining TZID dialects.
  • Backend single-event iCal endpoint (PR #1936 + backend-services #2766) β†’ superseded / closed: tracing the backend showed it inherited the same wall-clock-as-UTC bug, so it would have replaced one broken .ics path with another. The bug was instead fixed at the shared Spatie site. The backend endpoint may be repurposed for the separate org-dashboard "Subscribe to Calendar" use case.

8. Examples from other services​

Luma​

ButtonTarget / format
Google Calendarcalendar.google.com/calendar/render?action=TEMPLATE&dates=…Z/…Z&… β€” UTC Z
Outlook.comlabeled "Outlook.com" but points at outlook.office.com/calendar/0/deeplink/compose?…&startdt=…Z&enddt=…Z&… β€” UTC Z, no ctz
iCal (Apple / Outlook)downloads an .ics, explicitly labeled to cover both Apple and Outlook

Luma&#39;s &quot;Add to Calendar&quot; modal β€” Google Calendar, Outlook.com, and iCal (Apple / Outlook); the Outlook.com button links to outlook.office.com

Takeaway: Luma standardizes its single web Outlook button on outlook.office.com, uses UTC Z everywhere (including the Outlook link β€” no ctz), and names the ICS download "iCal (Apple / Outlook)". Our shipped design matches all three points (decisions 2 and 5).

Go Out (Eventribe)​

From the recorded walkthrough (Jam), the menu exposes four options:

  • Apple β†’ downloads an .ics file.
  • Google β†’ opens Google Calendar in a new tab.
  • Microsoft 365 β†’ opens outlook.office.com.
  • Outlook.com β†’ opens outlook.live.com (the recording notes this "outlook.com" entry is really outlook.live).

Go Out (Eventribe) add-to-calendar menu β€” Apple, Google, Microsoft 365, and Outlook.com

Takeaway: Go Out splits its Microsoft options exactly the way our shipped modal does β€” Microsoft 365 β†’ outlook.office.com and Outlook.com β†’ outlook.live.com β€” the same office.com-for-business / live.com-for-personal split (decisions 2–4).

9. References​

ClickUp​

  • 86caajc1e β€” [Sonoma] Change Outlook links to .office β€” main task
  • 86c9ryv0v β€” [Sonoma][Add to calendar] ICS exports are 1 hour ahead β€” related umbrella, done. Subtasks:
    • 86ca9j2va β€” remove the Outlook option & rename "Apple" β†’ "iCal" (done)
    • 86ca7wcvq β€” try TZID=Pacific Standard Time for old-Outlook compatibility (done)
    • 86ca3mzjc β€” drop the location link + move the event link to the end of the description (done)
    • 86ca6d294 β€” [Outlook] clickable link in description + correct time on save (done)

Pull requests (superco-consumer)​

  • #1971 β€” Add Office 365 add-to-calendar button (outlook.office.com, UTC Z) β€” merged (commit f888144)
  • #1937 β€” Apple Add-to-Calendar: use Spatie ics() generator β€” merged
  • #1936 β€” (superseded) route Apple link to a backend .ics endpoint β€” closed, unmerged
  • backend-services #2766 β€” (superseded) single-event iCal endpoint; may be repurposed for the org-dashboard "Subscribe to Calendar" issue (in backend-services; referenced, not verified here)

Code (superco-consumer)​

  • models/View/GroupsPageViewModel.php β†’ getCalendarLinks()
  • components/export/ICalendarBuilder.php (generate() = UTC; legacy buildGroupEvent())
  • components/export/OutlookOfficeBuilder.php (outlook.office.com, UTC Z)
  • assets/webpack/src/js/doc-ready/50-outlook-fix.js
  • migrations/m260617_120000_calendar_options_luma.php (#1965 β€” current labels)

Recordings (Jam)​