🗓️}
- />
- )
- expect(screen.getByTestId('custom-element-icon')).toBeInTheDocument()
- })
- })
-
- describe('event data handling', () => {
- it('handles minimal event data', () => {
- render(
-
- )
- const link = screen.getByRole('link')
- expect(link.getAttribute('href')).toContain('text=Minimal')
- })
-
- it('handles full event data', () => {
- render(
-
- )
- const link = screen.getByRole('link')
- const href = link.getAttribute('href') || ''
- expect(href).toContain('text=Full')
- expect(href).toContain('details=')
- expect(href).toContain('location=')
- })
-
- it('handles Date objects', () => {
- render(
-
- )
- const link = screen.getByRole('link')
- expect(link.getAttribute('href')).toContain('calendar.google.com')
- })
})
describe('reusability scenarios', () => {
@@ -288,9 +309,9 @@ describe('CalendarButton', () => {
iconClassName="h-4 w-4"
/>
)
- const link = screen.getByRole('link')
- expect(link).toHaveClass('text-gray-600')
- expect(link).toHaveClass('dark:text-gray-400')
+ const button = screen.getByRole('button')
+ expect(button).toHaveClass('text-gray-600')
+ expect(button).toHaveClass('dark:text-gray-400')
})
it('works in poster page context with label', () => {
@@ -306,20 +327,8 @@ describe('CalendarButton', () => {
/>
)
expect(screen.getByText('Save to Calendar')).toBeInTheDocument()
- const link = screen.getByRole('link')
- expect(link).toHaveClass('btn-primary')
- })
-
- it('works with custom styled icon', () => {
- render(
- }
- />
- )
- const svg = document.querySelector('svg')
- expect(svg).toHaveClass('h-6')
- expect(svg).toHaveClass('text-blue-500')
+ const button = screen.getByRole('button')
+ expect(button).toHaveClass('btn-primary')
})
})
@@ -337,9 +346,9 @@ describe('CalendarButton', () => {
className="flex-shrink-0"
/>
)
- const link = screen.getByRole('link')
- expect(link).toBeInTheDocument()
- expect(link).toHaveAttribute('aria-label', `Add ${longTitle} to Google Calendar`)
+ const button = screen.getByRole('button')
+ expect(button).toBeInTheDocument()
+ expect(button).toHaveAttribute('aria-label', `Add ${longTitle} to Calendar`)
})
it('maintains visibility with flex-shrink-0 class', () => {
@@ -352,9 +361,9 @@ describe('CalendarButton', () => {
className="flex-shrink-0 text-gray-600"
/>
)
- const link = screen.getByRole('link')
- expect(link).toHaveClass('flex-shrink-0')
- expect(link).toBeVisible()
+ const button = screen.getByRole('button')
+ expect(button).toHaveClass('flex-shrink-0')
+ expect(button).toBeVisible()
})
it('works correctly in flex container with long text sibling', () => {
@@ -372,47 +381,9 @@ describe('CalendarButton', () => {
/>
)
- const link = container.querySelector('a')
- expect(link).toBeInTheDocument()
- expect(link).toHaveClass('flex-shrink-0')
- })
-
- it('handles extremely long titles gracefully', () => {
- const extremelyLongTitle =
- 'This Is An Extremely Long Event Title With Many Words That Could Cause Layout Issues And Overflow Problems In Flex Containers With Limited Space Available'
- render(
-
- )
- const link = screen.getByRole('link')
- expect(link).toBeInTheDocument()
- expect(link.getAttribute('href')).toContain('calendar.google.com')
- })
-
- it('remains clickable when adjacent to truncated text', () => {
- const { container } = render(
-
-
- Another Very Long Text Element That Should Be Truncated With Ellipsis
-
-
-
- )
- const link = container.querySelector('a')
- expect(link).toBeInTheDocument()
- expect(link).not.toBeNull()
- const computedStyle = globalThis.getComputedStyle(link as Element)
- expect(computedStyle.display).not.toBe('none')
+ const button = container.querySelector('button[aria-label="Add Event to Calendar"]')
+ expect(button).toBeInTheDocument()
+ expect(button).toHaveClass('flex-shrink-0')
})
})
})
diff --git a/frontend/__tests__/unit/utils/getIcsFileUrl.test.ts b/frontend/__tests__/unit/utils/getIcsFileUrl.test.ts
new file mode 100644
index 0000000000..dadd248f2b
--- /dev/null
+++ b/frontend/__tests__/unit/utils/getIcsFileUrl.test.ts
@@ -0,0 +1,129 @@
+import { createEvent } from 'ics'
+import getIcsFileUrl from 'utils/getIcsFileUrl'
+
+describe('getIcsFileUrl', () => {
+ const originalCreateObjectURL = globalThis.URL.createObjectURL
+
+ const mockEvent = {
+ title: 'Conference',
+ description: 'Discuss Q4 goals',
+ location: 'Conference Room A',
+ url: 'https://meet.google.com/abc-defg-hij',
+ startDate: '2025-01-01',
+ endDate: '2025-01-03',
+ }
+
+ beforeAll(() => {
+ globalThis.URL.createObjectURL = jest.fn(() => 'blob:http://localhost/mock-uuid')
+ })
+
+ afterEach(() => {
+ jest.clearAllMocks()
+ })
+
+ afterAll(() => {
+ globalThis.URL.createObjectURL = originalCreateObjectURL
+ })
+
+ it('should generate a blob URL when event creation is successful', async () => {
+ ;(createEvent as jest.Mock).mockImplementation((attributes, callback) => {
+ callback(null, 'BEGIN:VCALENDAR...')
+ })
+
+ const result = await getIcsFileUrl(mockEvent)
+
+ expect(result).toBe('blob:http://localhost/mock-uuid')
+ expect(createEvent).toHaveBeenCalledTimes(1)
+ expect(globalThis.URL.createObjectURL).toHaveBeenCalledWith(expect.any(Blob))
+ })
+
+ it('should correctly format string dates into DateArray', async () => {
+ ;(createEvent as jest.Mock).mockImplementation((attr, cb) => cb(null, 'val'))
+
+ const eventWithStrings = {
+ ...mockEvent,
+ startDate: '2025-01-01',
+ endDate: '2025-01-03',
+ }
+
+ await getIcsFileUrl(eventWithStrings)
+
+ expect(createEvent).toHaveBeenCalledWith(
+ expect.objectContaining({
+ start: [2025, 1, 1],
+ end: [2025, 1, 3],
+ }),
+ expect.any(Function)
+ )
+ })
+
+ it('should correctly format Date objects into DateArray (handling 0-index months)', async () => {
+ ;(createEvent as jest.Mock).mockImplementation((attr, cb) => cb(null, 'val'))
+
+ const eventWithDateObjects = {
+ ...mockEvent,
+ startDate: new Date(2025, 0, 1),
+ endDate: new Date(2025, 0, 3),
+ }
+
+ await getIcsFileUrl(eventWithDateObjects)
+
+ expect(createEvent).toHaveBeenCalledWith(
+ expect.objectContaining({
+ start: [2025, 1, 1],
+ end: [2025, 1, 3],
+ }),
+ expect.any(Function)
+ )
+ })
+
+ it('should automatically increment the end date by 1 day if start and end dates are the same', async () => {
+ ;(createEvent as jest.Mock).mockImplementation((attr, cb) => cb(null, 'val'))
+
+ const singleDayEvent = {
+ ...mockEvent,
+ startDate: '2025-01-01',
+ endDate: '2025-01-01',
+ }
+
+ await getIcsFileUrl(singleDayEvent)
+
+ expect(createEvent).toHaveBeenCalledWith(
+ expect.objectContaining({
+ start: [2025, 1, 1],
+ end: [2025, 1, 2],
+ }),
+ expect.any(Function)
+ )
+ })
+
+ it('should reject the promise if createEvent returns an error', async () => {
+ const mockError = new Error('ICS generation failed')
+
+ ;(createEvent as jest.Mock).mockImplementation((attr, callback) => {
+ callback(mockError, null)
+ })
+
+ await expect(getIcsFileUrl(mockEvent)).rejects.toThrow('ICS generation failed')
+ })
+
+ it('should pass all event attributes correctly to createEvent', async () => {
+ ;(createEvent as jest.Mock).mockImplementation((attr, cb) => cb(null, 'val'))
+
+ await getIcsFileUrl(mockEvent)
+
+ expect(createEvent).toHaveBeenCalledWith(
+ {
+ start: [2025, 1, 1],
+ end: [2025, 1, 3],
+ title: mockEvent.title,
+ description: mockEvent.description,
+ location: mockEvent.location,
+ url: mockEvent.url,
+ status: 'CONFIRMED',
+ busyStatus: 'BUSY',
+ },
+ expect.any(Function)
+ )
+ })
+})
diff --git a/frontend/jest.setup.ts b/frontend/jest.setup.ts
index 63f36750dd..dd410b6e98 100644
--- a/frontend/jest.setup.ts
+++ b/frontend/jest.setup.ts
@@ -121,3 +121,10 @@ beforeEach(() => {
globalThis.runAnimationFrameCallbacks = jest.fn()
globalThis.removeAnimationFrameCallbacks = jest.fn()
})
+
+jest.mock('ics', () => {
+ return {
+ __esModule: true,
+ createEvent: jest.fn(),
+ }
+})
diff --git a/frontend/package.json b/frontend/package.json
index a9687032bd..abdd9e4e8e 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -43,6 +43,7 @@
"eslint-plugin-import": "^2.32.0",
"framer-motion": "^12.23.26",
"graphql": "^16.12.0",
+ "ics": "^3.8.1",
"leaflet": "^1.9.4",
"leaflet.markercluster": "^1.5.3",
"lodash": "^4.17.21",
diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml
index 1959411048..d48173a9b9 100644
--- a/frontend/pnpm-lock.yaml
+++ b/frontend/pnpm-lock.yaml
@@ -86,6 +86,9 @@ importers:
graphql:
specifier: ^16.12.0
version: 16.12.0
+ ics:
+ specifier: ^3.8.1
+ version: 3.8.1
leaflet:
specifier: ^1.9.4
version: 1.9.4
@@ -5029,6 +5032,9 @@ packages:
resolution: {integrity: sha512-2Tth85cXwGFHfvRgZWszZSvdo+0Xsqmw8k8ZwxScfcBneNUraK+dxRxRm24nszx80Y0TVio8kKLt5sLE7ZCLlw==}
engines: {node: '>=0.10.0'}
+ ics@3.8.1:
+ resolution: {integrity: sha512-UqQlfkajfhrS4pUGQfGIJMYz/Jsl/ob3LqcfEhUmLbwumg+ZNkU0/6S734Vsjq3/FYNpEcZVKodLBoe+zBM69g==}
+
identity-obj-proxy@3.0.0:
resolution: {integrity: sha512-00n6YnVHKrinT9t0d9+5yZC6UBNJANpYEQvL2LlX6Ab9lnmxzIRcEmTPuyGScvl1+jKuCICX1Z0Ab1pPKKdikA==}
engines: {node: '>=4'}
@@ -6397,6 +6403,9 @@ packages:
prop-types@15.8.1:
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
+ property-expr@2.0.6:
+ resolution: {integrity: sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==}
+
proxy-addr@2.0.7:
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
engines: {node: '>= 0.10'}
@@ -6614,6 +6623,9 @@ packages:
run-parallel@1.2.0:
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
+ runes2@1.1.4:
+ resolution: {integrity: sha512-LNPnEDPOOU4ehF71m5JoQyzT2yxwD6ZreFJ7MxZUAoMKNMY1XrAo60H1CUoX5ncSm0rIuKlqn9JZNRrRkNou2g==}
+
rxjs@6.6.7:
resolution: {integrity: sha512-hTdwr+7yYNIT5n4AMYp85KA6yw2Va0FLa3Rguvbpa4W3I5xynaBZo41cM3XM+4Q6fRMj3sBYIR1VAmZMXYJvRQ==}
engines: {npm: '>=2.0.0'}
@@ -7022,6 +7034,9 @@ packages:
resolution: {integrity: sha512-YBGpG4bWsHoPvofT6y/5iqulfXIiIErl5B0LdtHT1mGXDFTAhhRrbUpTvBgYbovr+3cKblya2WAOcpoy90XguA==}
engines: {node: '>=16'}
+ tiny-case@1.0.3:
+ resolution: {integrity: sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==}
+
tinyglobby@0.2.15:
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
engines: {node: '>=12.0.0'}
@@ -7058,6 +7073,9 @@ packages:
resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==}
engines: {node: '>=0.6'}
+ toposort@2.0.2:
+ resolution: {integrity: sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==}
+
tough-cookie@5.1.2:
resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==}
engines: {node: '>=16'}
@@ -7151,6 +7169,10 @@ packages:
resolution: {integrity: sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg==}
engines: {node: '>=8'}
+ type-fest@2.19.0:
+ resolution: {integrity: sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==}
+ engines: {node: '>=12.20'}
+
type-fest@4.41.0:
resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==}
engines: {node: '>=16'}
@@ -7518,6 +7540,9 @@ packages:
resolution: {integrity: sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==}
engines: {node: '>=18'}
+ yup@1.7.1:
+ resolution: {integrity: sha512-GKHFX2nXul2/4Dtfxhozv701jLQHdf6J34YDh2cEkpqoo8le5Mg6/LrdseVLrFarmFygZTlfIhHx/QKfb/QWXw==}
+
zod-validation-error@4.0.2:
resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==}
engines: {node: '>=18.0.0'}
@@ -13748,6 +13773,12 @@ snapshots:
dependencies:
safer-buffer: 2.1.2
+ ics@3.8.1:
+ dependencies:
+ nanoid: 3.3.11
+ runes2: 1.1.4
+ yup: 1.7.1
+
identity-obj-proxy@3.0.0:
dependencies:
harmony-reflect: 1.6.2
@@ -15254,6 +15285,8 @@ snapshots:
object-assign: 4.1.1
react-is: 16.13.1
+ property-expr@2.0.6: {}
+
proxy-addr@2.0.7:
dependencies:
forwarded: 0.2.0
@@ -15513,6 +15546,8 @@ snapshots:
dependencies:
queue-microtask: 1.2.3
+ runes2@1.1.4: {}
+
rxjs@6.6.7:
dependencies:
tslib: 1.14.1
@@ -16018,6 +16053,8 @@ snapshots:
timeout-signal@2.0.0: {}
+ tiny-case@1.0.3: {}
+
tinyglobby@0.2.15:
dependencies:
fdir: 6.5.0(picomatch@4.0.3)
@@ -16053,6 +16090,8 @@ snapshots:
toidentifier@1.0.1: {}
+ toposort@2.0.2: {}
+
tough-cookie@5.1.2:
dependencies:
tldts: 6.1.86
@@ -16134,6 +16173,8 @@ snapshots:
type-fest@0.7.1: {}
+ type-fest@2.19.0: {}
+
type-fest@4.41.0: {}
type-is@1.6.18:
@@ -16552,6 +16593,13 @@ snapshots:
yoctocolors-cjs@2.1.3: {}
+ yup@1.7.1:
+ dependencies:
+ property-expr: 2.0.6
+ tiny-case: 1.0.3
+ toposort: 2.0.2
+ type-fest: 2.19.0
+
zod-validation-error@4.0.2(zod@4.2.1):
dependencies:
zod: 4.2.1
diff --git a/frontend/src/components/CalendarButton.tsx b/frontend/src/components/CalendarButton.tsx
index 0c95745851..92c0376044 100644
--- a/frontend/src/components/CalendarButton.tsx
+++ b/frontend/src/components/CalendarButton.tsx
@@ -1,30 +1,71 @@
+'use client'
+
+import { addToast } from '@heroui/toast'
import { useState } from 'react'
import { FaCalendar, FaCalendarPlus } from 'react-icons/fa6'
import type { CalendarButtonProps } from 'types/calendar'
-import getGoogleCalendarUrl from 'utils/getGoogleCalendarUrl'
+import getIcsFileUrl from 'utils/getIcsFileUrl'
+import slugify from 'utils/slugify'
export default function CalendarButton(props: Readonly) {
const [isHovered, setIsHovered] = useState(false)
+ const [isDownloading, setIsDownloading] = useState(false)
const {
event,
className = '',
iconClassName = 'h-4 w-4',
icon,
showLabel = false,
- label = 'Add to Google Calendar',
+ label = 'Add to Calendar',
} = props
- const href = getGoogleCalendarUrl(event)
const safeTitle = event.title || 'event'
- const ariaLabel = `Add ${safeTitle} to Google Calendar`
+ const ariaLabel = `Add ${safeTitle} to Calendar`
+
+ const handleDownload = async () => {
+ let url: string | null = null
+ let link: HTMLAnchorElement | null = null
+ try {
+ setIsDownloading(true)
+ url = await getIcsFileUrl(event)
+ link = document.createElement('a')
+ link.href = url
+ link.setAttribute('download', `${slugify(safeTitle)}.ics`)
+ document.body.appendChild(link)
+ link.click()
+ addToast({
+ description: 'Successfully downloaded ICS file',
+ title: `${event.title}`,
+ timeout: 3000,
+ shouldShowTimeoutProgress: true,
+ color: 'success',
+ variant: 'solid',
+ })
+ } catch (err) {
+ // eslint-disable-next-line no-console
+ console.error('Failed to download ICS file:', err)
+ addToast({
+ description: 'Failed to download ICS file',
+ title: 'Download Failed',
+ timeout: 3000,
+ shouldShowTimeoutProgress: true,
+ color: 'danger',
+ variant: 'solid',
+ })
+ } finally {
+ if (link) link.remove()
+ if (url) URL.revokeObjectURL(url)
+ setIsDownloading(false)
+ }
+ }
return (
- setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
@@ -35,6 +76,6 @@ export default function CalendarButton(props: Readonly) {
))}
{showLabel && {label}}
-
+
)
}
diff --git a/frontend/src/utils/getIcsFileUrl.ts b/frontend/src/utils/getIcsFileUrl.ts
new file mode 100644
index 0000000000..0b63ac420c
--- /dev/null
+++ b/frontend/src/utils/getIcsFileUrl.ts
@@ -0,0 +1,53 @@
+import { createEvent, DateArray, EventAttributes } from 'ics'
+import type { CalendarEvent } from 'types/calendar'
+
+export default function getIcsFileUrl(event: CalendarEvent): Promise {
+ return new Promise((resolve, reject) => {
+ if (globalThis.window === undefined) {
+ reject(new Error('Window not defined (server-side generation not supported)'))
+ return
+ }
+ const parseDate = (date: string | Date): DateArray => {
+ if (typeof date === 'string') {
+ const [year, month, day] = date.split('-').map(Number)
+ if (!year || !month || !day) throw new Error(`Invalid date string: ${date}`)
+ return [year, month, day]
+ }
+ return [date.getFullYear(), date.getMonth() + 1, date.getDate()]
+ }
+
+ const getEndDate = (start: DateArray, end: DateArray): DateArray => {
+ if (start.join('-') === end.join('-')) {
+ const [y, m, d] = end
+ const nextDay = new Date(y, m - 1, d + 1)
+ return [nextDay.getFullYear(), nextDay.getMonth() + 1, nextDay.getDate()]
+ }
+ return end
+ }
+
+ const startArray = parseDate(event.startDate)
+ const rawEndArray = parseDate(event.endDate ?? event.startDate)
+ const finalEndArray = getEndDate(startArray, rawEndArray)
+
+ const eventAttributes: EventAttributes = {
+ start: startArray,
+ end: finalEndArray,
+ title: event.title,
+ status: 'CONFIRMED',
+ busyStatus: 'BUSY',
+ ...(event.description && { description: event.description }),
+ ...(event.location && { location: event.location }),
+ ...(event.url && { url: event.url }),
+ }
+
+ createEvent(eventAttributes, (error, value) => {
+ if (error) {
+ reject(error)
+ return
+ }
+
+ const blob = new Blob([value], { type: 'text/calendar;charset=utf-8' })
+ resolve(globalThis.URL.createObjectURL(blob))
+ })
+ })
+}
diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json
index 457f077a06..72ccd27a56 100644
--- a/frontend/tsconfig.json
+++ b/frontend/tsconfig.json
@@ -11,7 +11,7 @@
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
- "jsx": "preserve",
+ "jsx": "react-jsx",
"incremental": true,
"sourceMap": true,
"inlineSources": true,