diff --git a/components/stream/AddToCalendarButton.tsx b/components/stream/AddToCalendarButton.tsx index d584016..264c288 100644 --- a/components/stream/AddToCalendarButton.tsx +++ b/components/stream/AddToCalendarButton.tsx @@ -1,7 +1,7 @@ 'use client'; -import { CalendarPlus } from 'lucide-react'; -import { buildIcsEvent, downloadIcsFile } from '@/lib/calendar'; +import { CalendarPlus, ChevronDown, Download } from 'lucide-react'; +import { buildGoogleCalendarUrl, buildIcsEvent, downloadIcsFile } from '@/lib/calendar'; interface AddToCalendarButtonProps { streamId: string; @@ -12,25 +12,41 @@ interface AddToCalendarButtonProps { /** Downloads a .ics file reminding the user when a stream ends (#566). */ export function AddToCalendarButton({ streamId, streamAddress, endTime }: AddToCalendarButtonProps) { + const contractUrl = `https://stellar.expert/explorer/public/contract/${streamAddress}`; + const title = `Stream #${streamId} ends`; + const description = `Conduit stream #${streamId} completes at this time.\n\nContract: ${contractUrl}`; + function handleClick() { const ics = buildIcsEvent({ id: streamAddress, - title: `Stream #${streamId} ends`, + title, timestamp: endTime, - description: `Conduit stream #${streamId} completes at this time.`, + description, }); downloadIcsFile(`conduit-stream-${streamId}.ics`, ics); } return ( - +
+ + +
+ + Add to Google Calendar + + +
+
); } diff --git a/components/stream/__tests__/AddToCalendarButton.test.tsx b/components/stream/__tests__/AddToCalendarButton.test.tsx index dd1100b..d00342d 100644 --- a/components/stream/__tests__/AddToCalendarButton.test.tsx +++ b/components/stream/__tests__/AddToCalendarButton.test.tsx @@ -4,13 +4,15 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { AddToCalendarButton } from '../AddToCalendarButton'; vi.mock('@/lib/calendar', () => ({ + buildGoogleCalendarUrl: vi.fn(() => 'https://calendar.google.com/calendar/render?event'), buildIcsEvent: vi.fn(() => 'ICS_CONTENT'), downloadIcsFile: vi.fn(), })); -import { buildIcsEvent, downloadIcsFile } from '@/lib/calendar'; +import { buildGoogleCalendarUrl, buildIcsEvent, downloadIcsFile } from '@/lib/calendar'; const mockBuildIcsEvent = vi.mocked(buildIcsEvent); +const mockBuildGoogleCalendarUrl = vi.mocked(buildGoogleCalendarUrl); const mockDownloadIcsFile = vi.mocked(downloadIcsFile); describe('AddToCalendarButton', () => { @@ -29,16 +31,16 @@ describe('AddToCalendarButton', () => { container.remove(); }); - it('renders a labeled button', () => { + it('renders a calendar menu with Google and iCalendar options', () => { act(() => { root.render( , ); }); - const button = container.querySelector('button'); - expect(button).toBeTruthy(); - expect(button?.textContent).toContain('Add to calendar'); + expect(container.querySelector('summary')?.textContent).toContain('Add to calendar'); + expect(container.querySelector('a')?.textContent).toContain('Google Calendar'); + expect(container.querySelector('button')?.textContent).toContain('Download .ics'); }); it('builds and downloads an .ics file with the stream end date on click', () => { @@ -61,4 +63,19 @@ describe('AddToCalendarButton', () => { }); expect(mockDownloadIcsFile).toHaveBeenCalledWith('conduit-stream-42.ics', 'ICS_CONTENT'); }); + + it('links to a Google Calendar event including the stream contract URL', () => { + act(() => { + root.render( + , + ); + }); + + expect(container.querySelector('a')?.getAttribute('href')).toContain('calendar.google.com'); + expect(mockBuildGoogleCalendarUrl).toHaveBeenCalledWith(expect.objectContaining({ + title: 'Stream #42 ends', + timestamp: 1_700_000_000, + url: expect.stringContaining('CSTREAMADDR'), + })); + }); }); diff --git a/lib/calendar.test.ts b/lib/calendar.test.ts index 37a2b43..0d2fa25 100644 --- a/lib/calendar.test.ts +++ b/lib/calendar.test.ts @@ -9,7 +9,7 @@ */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { toIcsUtcDate, buildIcsEvent, downloadIcsFile } from './calendar'; +import { buildGoogleCalendarUrl, toIcsUtcDate, buildIcsEvent, downloadIcsFile } from './calendar'; describe('toIcsUtcDate', () => { it('formats a unix timestamp as YYYYMMDDTHHMMSSZ in UTC', () => { @@ -70,6 +70,25 @@ describe('buildIcsEvent', () => { }); }); +describe('buildGoogleCalendarUrl', () => { + it('creates a pre-filled event with UTC dates and the contract URL in its details', () => { + const timestamp = Date.UTC(2024, 5, 1, 12, 0, 0) / 1000; + const url = buildGoogleCalendarUrl({ + id: 'CSTREAMADDR', + title: 'Stream #42 ends', + timestamp, + description: 'The stream completes at this time.', + url: 'https://stellar.expert/explorer/public/contract/CSTREAMADDR', + }); + + const parsed = new URL(url); + expect(parsed.origin + parsed.pathname).toBe('https://calendar.google.com/calendar/render'); + expect(parsed.searchParams.get('action')).toBe('TEMPLATE'); + expect(parsed.searchParams.get('dates')).toBe('20240601T120000Z/20240601T120000Z'); + expect(parsed.searchParams.get('details')).toContain('CSTREAMADDR'); + }); +}); + describe('downloadIcsFile', () => { const originalCreateObjectURL = URL.createObjectURL; const originalRevokeObjectURL = URL.revokeObjectURL; diff --git a/lib/calendar.ts b/lib/calendar.ts index 4179462..a05e86f 100644 --- a/lib/calendar.ts +++ b/lib/calendar.ts @@ -14,6 +14,12 @@ export interface CalendarEventInput { description?: string; } +/** Details needed to open a pre-filled Google Calendar event. */ +export interface GoogleCalendarEventInput extends CalendarEventInput { + /** A URL to the on-chain stream contract. */ + url: string; +} + function pad(n: number): string { return n.toString().padStart(2, '0'); } @@ -67,6 +73,27 @@ export function buildIcsEvent({ id, title, timestamp, description }: CalendarEve return lines.join('\r\n') + '\r\n'; } +/** + * Return a Google Calendar "create event" URL for a point-in-time stream + * reminder. Google uses the same UTC timestamps as an iCalendar event, so + * recipients see the completion time in their own timezone. + */ +export function buildGoogleCalendarUrl({ + title, + timestamp, + description, + url, +}: GoogleCalendarEventInput): string { + const date = toIcsUtcDate(timestamp); + const params = new URLSearchParams({ + action: 'TEMPLATE', + text: title, + dates: `${date}/${date}`, + details: [description, url].filter(Boolean).join('\n\n'), + }); + return `https://calendar.google.com/calendar/render?${params.toString()}`; +} + /** * Trigger a browser download of the given .ics content. No-op outside the * browser (SSR) since there's no `document` to build an anchor from.