Design Doc: Add to Calendar (superco-consumer)
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.php | Spatie 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.php | Spatie 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.js | Client-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) | Logo | Target | Time encoding | Notes |
|---|---|---|---|---|
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 it | Works 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 path | Works 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 .ics | UTC 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.js | outlook.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$icsContentas the Apple button. #1971 repurposed that slot β same365-calendar.pnglogo, newcalendar_office365_textlabel β for theoutlook.office.comweb 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β
| Client | How it is served | Why |
|---|---|---|
| Apple Calendar (macOS / iOS) | .ics download with UTC Z times | Apple converts the UTC instant to the device's local time correctly |
| Old desktop Outlook | .ics download with UTC Z times | Old 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 deeplink | A 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.js | The outlook.live.com deeplink only authenticates personal Microsoft accounts |
| Google Calendar | $link->google() render URL | Timezone 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.
Decision 2 β the Office 365 web link also emits UTC (Z), with no ctzβ
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/enddtplusctz=<community-timezone>derived fromcommunity->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 β
addressis 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$outlookLinkdescription (this$outlookLinkfeeds 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:
- 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 hourblock and a+2 hours"Sonoma fix" end-time made it worse and inconsistent across regions. - PR #1937 (merged) repointed the Apple button to Spatie
$link->ics(), which emitted the correct instant asDTSTART;TZID=America/Los_Angeles:β¦. - Client QA then reported that older Outlook couldn't read
TZID=America/Los_Angeles; subtask 86ca7wcvq triedTZID=Pacific Standard Time(the Windows zone id). - Final (ICS): rather than juggle
TZIDdialects, the ICS now emits a plain UTCZinstant (currentICalendarBuilder::generate()), matching Sonoma's own feed (DTSTART:20260501T130000Z) and validated in Google / Apple / Outlook. - Office 365 button (86caajc1e / PR #1971, merged β commit
f888144): theoutlook.office.comweb button shipped, replacing the old Outlook 365 ICS entry and closing the personal-vs-business gap left by theoutlook.live.combutton β also on UTCZ.
6. QA validation snapshotβ
Point-in-time QA from subtask 86ca3mzjc (Sonoma event 260168456) β the snapshot that drove the follow-up Outlook fix (86ca6d294):
| Calendar | Hours | Description | Location |
|---|---|---|---|
| β 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-UTCctz, 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 aVTIMEZONEblock.TZID=Pacific Standard Time(Windows zone id, subtask 86ca7wcvq) β abandoned in favor of a plain UTC instant rather than maintainingTZIDdialects.- 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.icspath 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β
| Button | Target / format |
|---|---|
| Google Calendar | calendar.google.com/calendar/render?action=TEMPLATE&dates=β¦Z/β¦Z&β¦ β UTC Z |
| Outlook.com | labeled "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 |

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
.icsfile. - 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 reallyoutlook.live).

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 Timefor 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, UTCZ) β merged (commitf888144) - #1937 β Apple Add-to-Calendar: use Spatie
ics()generator β merged - #1936 β (superseded) route Apple link to a backend
.icsendpoint β closed, unmerged backend-services#2766 β (superseded) single-event iCal endpoint; may be repurposed for the org-dashboard "Subscribe to Calendar" issue (inbackend-services; referenced, not verified here)
Code (superco-consumer)β
models/View/GroupsPageViewModel.phpβgetCalendarLinks()components/export/ICalendarBuilder.php(generate()= UTC; legacybuildGroupEvent())components/export/OutlookOfficeBuilder.php(outlook.office.com, UTCZ)assets/webpack/src/js/doc-ready/50-outlook-fix.jsmigrations/m260617_120000_calendar_options_luma.php(#1965 β current labels)
Recordings (Jam)β
- Go Out / Eventribe walkthrough β jam.dev/c/68f01ac8β¦
- Apple Calendar still off β jam.dev/c/4f10277eβ¦