diff --git a/cspell/custom-dict.txt b/cspell/custom-dict.txt index 6e228a0bf3..eee0da1ebe 100644 --- a/cspell/custom-dict.txt +++ b/cspell/custom-dict.txt @@ -69,6 +69,7 @@ gunicorn hackathon heroui hsl +ics igoat inlinehilite isanori diff --git a/frontend/__tests__/e2e/pages/CalendarButton.spec.ts b/frontend/__tests__/e2e/pages/CalendarButton.spec.ts new file mode 100644 index 0000000000..c4fd2a875b --- /dev/null +++ b/frontend/__tests__/e2e/pages/CalendarButton.spec.ts @@ -0,0 +1,48 @@ +import fs from 'node:fs' +import { mockHomeData } from '@e2e/data/mockHomeData' +import { test, expect } from '@playwright/test' +import slugify from 'utils/slugify' + +test.describe('Calendar Export Functionality', () => { + test.beforeEach(async ({ page }) => { + await page.route('**/graphql/', async (route) => { + await route.fulfill({ + status: 200, + json: mockHomeData, + }) + }) + await page.context().addCookies([ + { + name: 'csrftoken', + value: 'abc123', + domain: 'localhost', + path: '/', + }, + ]) + await page.goto('/') + }) + + test('should download a valid ICS file when clicked', async ({ page }) => { + const calendarButton = page.getByRole('button', { name: 'Add Event 1 to Calendar' }) + await expect(calendarButton).toBeVisible() + + const downloadPromise = page.waitForEvent('download') + + await calendarButton.click() + + const download = await downloadPromise + + expect(download.suggestedFilename()).toBe(`${slugify('Event 1')}.ics`) + + const path = await download.path() + expect(path, 'Expected Playwright to provide a download path').toBeTruthy() + const content = fs.readFileSync(path, 'utf-8') + + expect(content).toContain('BEGIN:VCALENDAR') + expect(content).toContain('VERSION:2.0') + expect(content).toContain('BEGIN:VEVENT') + + expect(content).toContain('SUMMARY:') + expect(content).toContain('END:VCALENDAR') + }) +}) diff --git a/frontend/__tests__/e2e/pages/Home.spec.ts b/frontend/__tests__/e2e/pages/Home.spec.ts index 7d0733054e..d82c879abe 100644 --- a/frontend/__tests__/e2e/pages/Home.spec.ts +++ b/frontend/__tests__/e2e/pages/Home.spec.ts @@ -100,9 +100,9 @@ test.describe('Home Page', () => { await expect(page.getByRole('heading', { name: 'Upcoming Events' })).toBeVisible({ timeout: 10000, }) - await expect(page.getByRole('button', { name: 'Event 1' })).toBeVisible() + await expect(page.getByRole('button', { name: 'Event 1', exact: true })).toBeVisible() await expect(page.getByText('Apr 5 — 6, 2025')).toBeVisible() - await page.getByRole('button', { name: 'Event 1' }).click() + await page.getByRole('button', { name: 'Event 1', exact: true }).click() }) test('should have stats', async ({ page }) => { diff --git a/frontend/__tests__/unit/components/CalendarButton.test.tsx b/frontend/__tests__/unit/components/CalendarButton.test.tsx index db66d6e144..55203cb7e1 100644 --- a/frontend/__tests__/unit/components/CalendarButton.test.tsx +++ b/frontend/__tests__/unit/components/CalendarButton.test.tsx @@ -1,7 +1,20 @@ -import { render, screen } from '@testing-library/react' +/** + * @jest-environment jsdom + */ + +import { addToast } from '@heroui/toast' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { FaCalendarDay, FaCalendarPlus } from 'react-icons/fa6' +import getIcsFileUrl from 'utils/getIcsFileUrl' +import slugify from 'utils/slugify' import CalendarButton from 'components/CalendarButton' +jest.mock('utils/getIcsFileUrl') + +jest.mock('@heroui/toast', () => ({ + addToast: jest.fn(), +})) + const mockEvent = { title: 'Test Event', description: 'Test description', @@ -11,17 +24,41 @@ const mockEvent = { } describe('CalendarButton', () => { + const mockUrl = 'blob:http://localhost/mock-file' + + let appendSpy: jest.SpyInstance + let clickSpy: jest.SpyInstance + let createSpy: jest.SpyInstance + + beforeEach(() => { + jest.clearAllMocks() + globalThis.URL.createObjectURL = jest.fn(() => 'mock-url') + globalThis.URL.revokeObjectURL = jest.fn() + ;(getIcsFileUrl as jest.Mock).mockResolvedValue(mockUrl) + + appendSpy = jest.spyOn(document.body, 'appendChild') + createSpy = jest.spyOn(document, 'createElement') + + clickSpy = jest.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}) + + jest.spyOn(globalThis, 'alert').mockImplementation(() => {}) + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + describe('rendering', () => { it('renders without crashing', () => { render() - const link = screen.getByRole('link') - expect(link).toBeInTheDocument() + const button = screen.getByRole('button') + expect(button).toBeInTheDocument() }) - it('renders as an anchor element', () => { + it('renders as a button element', () => { render() - const link = screen.getByRole('link') - expect(link.tagName).toBe('A') + const button = screen.getByRole('button') + expect(button.tagName).toBe('BUTTON') }) it('renders default calendar-plus icon', () => { @@ -44,65 +81,117 @@ describe('CalendarButton', () => { }) }) - describe('link attributes', () => { - it('has correct href with Google Calendar URL', () => { + describe('functionality (download)', () => { + it('generates ICS file and triggers download on click', async () => { render() - const link = screen.getByRole('link') - expect(link).toHaveAttribute('href') - expect(link.getAttribute('href')).toContain('calendar.google.com') - }) + const button = screen.getByRole('button') - it('opens in new tab', () => { - render() - const link = screen.getByRole('link') - expect(link).toHaveAttribute('target', '_blank') + fireEvent.click(button) + + expect(button).toBeDisabled() + + await waitFor(() => { + expect(getIcsFileUrl).toHaveBeenCalledWith(mockEvent) + }) + + expect(createSpy).toHaveBeenCalledWith('a') + const createdLink = createSpy.mock.results.find( + (call) => call.value instanceof HTMLAnchorElement && call.value.href === mockUrl + )?.value + + expect(createdLink).toBeDefined() + expect(createdLink.download).toBe(`${slugify(mockEvent.title)}.ics`) + expect(appendSpy).toHaveBeenCalledWith(createdLink) + expect(clickSpy).toHaveBeenCalled() + + expect(addToast).toHaveBeenCalledWith({ + description: 'Successfully downloaded ICS file', + title: `${mockEvent.title}`, + timeout: 3000, + shouldShowTimeoutProgress: true, + color: 'success', + variant: 'solid', + }) + + await waitFor(() => { + expect(button).not.toBeDisabled() + }) + + expect(addToast).toHaveBeenCalledTimes(1) }) - it('has security attributes for external link', () => { + it('handles errors gracefully when generation fails', async () => { + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}) + const errorMock = new Error('Failed to generate') + ;(getIcsFileUrl as jest.Mock).mockRejectedValueOnce(errorMock) + render() - const link = screen.getByRole('link') - expect(link).toHaveAttribute('rel', 'noopener noreferrer') + const button = screen.getByRole('button') + + fireEvent.click(button) + + await waitFor(() => { + expect(addToast).toHaveBeenCalledWith({ + description: 'Failed to download ICS file', + title: 'Download Failed', + timeout: 3000, + shouldShowTimeoutProgress: true, + color: 'danger', + variant: 'solid', + }) + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('Failed to download ICS file'), + errorMock + ) + }) + + await waitFor(() => { + expect(button).not.toBeDisabled() + }) + + expect(addToast).not.toHaveBeenCalledWith( + expect.objectContaining({ + color: 'success', + title: `${mockEvent.title}`, + }) + ) + expect(addToast).toHaveBeenCalledTimes(1) + consoleSpy.mockRestore() }) }) describe('accessibility', () => { it('has aria-label with event title', () => { render() - const link = screen.getByRole('link') - expect(link).toHaveAttribute('aria-label', 'Add Test Event to Google Calendar') - }) - - it('has title attribute for tooltip', () => { - render() - const link = screen.getByRole('link') - expect(link).toHaveAttribute('title', 'Add Test Event to Google Calendar') + const button = screen.getByRole('button') + expect(button).toHaveAttribute('aria-label', 'Add Test Event to Calendar') }) it('uses fallback for events without explicit title', () => { render() - const link = screen.getByRole('link') - expect(link).toHaveAttribute('aria-label', 'Add Untitled to Google Calendar') + const button = screen.getByRole('button') + expect(button).toHaveAttribute('aria-label', 'Add Untitled to Calendar') }) }) describe('className prop', () => { - it('applies className to anchor', () => { + it('applies className to button', () => { render() - const link = screen.getByRole('link') - expect(link).toHaveClass('custom-class') + const button = screen.getByRole('button') + expect(button).toHaveClass('custom-class') }) it('applies multiple classes', () => { render() - const link = screen.getByRole('link') - expect(link).toHaveClass('class-one') - expect(link).toHaveClass('class-two') + const button = screen.getByRole('button') + expect(button).toHaveClass('class-one') + expect(button).toHaveClass('class-two') }) it('handles empty className', () => { render() - const link = screen.getByRole('link') - expect(link).toBeInTheDocument() + const button = screen.getByRole('button') + expect(button).toBeInTheDocument() }) }) @@ -124,11 +213,6 @@ describe('CalendarButton', () => { svg = document.querySelector('svg') expect(svg).toHaveClass('h-6', 'w-6') expect(svg).not.toHaveClass('h-4') - - rerender() - svg = document.querySelector('svg') - expect(svg).toHaveClass('h-8', 'w-8') - expect(svg).not.toHaveClass('h-6') }) it('uses default iconClassName when not provided', () => { @@ -153,25 +237,25 @@ describe('CalendarButton', () => { describe('showLabel prop', () => { it('does not show label by default', () => { render() - expect(screen.queryByText('Add to Google Calendar')).not.toBeInTheDocument() + expect(screen.queryByText('Add to Calendar')).not.toBeInTheDocument() }) it('shows default label when showLabel is true', () => { render() - expect(screen.getByText('Add to Google Calendar')).toBeInTheDocument() + expect(screen.getByText('Add to Calendar')).toBeInTheDocument() }) it('shows custom label when provided', () => { render() expect(screen.getByText('Save Event')).toBeInTheDocument() - expect(screen.queryByText('Add to Google Calendar')).not.toBeInTheDocument() + expect(screen.queryByText('Add to Calendar')).not.toBeInTheDocument() }) }) describe('label prop', () => { it('uses custom label text', () => { - render() - expect(screen.getByText('Add to Calendar')).toBeInTheDocument() + render() + expect(screen.getByText('Export ICS')).toBeInTheDocument() }) it('label is not rendered without showLabel', () => { @@ -208,69 +292,6 @@ describe('CalendarButton', () => { ) expect(screen.getByTestId('svg-icon')).toBeInTheDocument() }) - - it('accepts emoji as icon', () => { - render(📅} />) - expect(screen.getByText('📅')).toBeInTheDocument() - }) - - it('accepts custom element as icon', () => { - render( - 🗓️} - /> - ) - 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,