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 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.